diff --git a/AGENTS.md b/AGENTS.md index 3b5079c..6805219 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,91 +128,13 @@ remote reader to keep in step. Check what the PC currently resolves: `python -m wake_alarm._sync --show`. Check what it would do right now: `python -m wake_alarm._trigger --dry-run`. -## The phone rings on its own — hand-written Kotlin, one MethodChannel +## The phone rings on its own -`phone_app/` is no longer just a time picker. It arms and rings its own alarm -(items 1/4) through five Kotlin files behind **one** MethodChannel -(`AlarmChannel.kt`) on `MainActivity`/`AlarmActivity`: - -`AlarmScheduler` (arm/cancel/re-arm) · `AlarmReceiver` (fires) · -`AlarmService` (foreground service: wakelock, sound, vibration, full-screen -intent) · `AlarmActivity` (dismiss screen over the lockscreen) · -`BootReceiver` (re-arm after reboot) · `StepCounter` (item 3 fallback). - -**Never replace any of this with a pub plugin.** The stack is AGP 9.0.1 / -Gradle 9.1 / Kotlin 2.3.20; a plugin that will not compile against AGP 9 breaks -the build outright rather than degrading, and this is the one path that must -not break. Third-party plugins are acceptable only for droppable extras (QR -scanning) — never for the ring path. - -Things that are a hard crash or a silent no-ring, not a warning: - -- **`STREAM_ALARM` is load-bearing.** `AlarmService` sets - `AudioAttributes.USAGE_ALARM`, which is what ignores DND and silent mode. - Never `USAGE_MEDIA`/`STREAM_MUSIC` — it would be muted exactly when the - alarm matters most. -- **`android:foregroundServiceType="mediaPlayback"` must be on the `` - in the manifest AND passed to `startForeground()`.** Omitting either is a - hard crash the moment the alarm fires on API 34+ — and it is invisible on an - API 33 device, which is why the API 36 AVD exists. -- **`setAlarmClock()`, not `setExactAndAllowWhileIdle()`.** It is Doze-exempt, - needs no `SCHEDULE_EXACT_ALARM` prompt (`USE_EXACT_ALARM` covers an - alarm-clock app), and it is what grants the receiver its foreground-service - exemption when it fires with the screen off. -- **`FLAG_IMMUTABLE` on every `PendingIntent`** — mandatory since API 31; a - mutable one throws at arm time. -- **`canUseFullScreenIntent()` gates, it does not warn.** From API 34 the grant - can be revoked; `_armOwnAlarm()` in `home_screen.dart` refuses to report - success and tells the user to grant it, because without it the alarm posts a - notification the sleeping user never sees. -- **`min/target/compileSdk` are pinned explicitly** (28/36/36) in - `android/app/build.gradle.kts`. They used to be delegated to the Flutter - Gradle plugin, so there was no floor to reason about. minSdk 28 is what makes - `android:showWhenLocked`/`turnScreenOn` plain manifest attributes. - -**Re-arm has three hooks, and needs all three.** `LogStore` has **no change -stream** (the plan claimed one; it does not exist in `crdt_sync_dart`), so the -picker's save is only one of them: `main()`'s startup `rearm()`, -`BOOT_COMPLETED`, and the dismiss handler. Drop any one and the alarm rings -once and then silently stops. - -**The phone rings daily**, matching the PC's `ALARM_DAYS` (all seven days). -The two are deliberately not synced — a shared day set is item 8's shape, and -diverging defaults would have the halves firing on different days. - -`lib/services/challenge.dart` is a port of `_challenges.py`'s five kinds so -both halves behave identically; `kDismissCodeLength`/`kDismissFlashSeconds` -are **duplicated** from `_constants.py`, not synced. Change one, change both. - -Room proof (item 3) is `lib/services/room_proof.dart`: **NFC or QR satisfies -on its own; steps alone never does** — the phone is stationary while you walk -to it, so steps only accrue after pickup and can be faked by shaking it in -bed. Steps are accepted only together with elapsed time, as the fallback for -when both other mechanisms fail. - -**`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test -handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the -normal arm path needs a sync backend connected, so there is no other way to -fire the ring on an emulator. The probe runs the **real** app widget and -pushes `/ring` — an earlier version rendered its own UI and so proved nothing -about the route. `flutter build apk` uses `lib/main.dart`, so the probe is -never the shipped entrypoint; `ringNow` is reachable only over this app's own -channel and arms nothing. Do not "clean these up" without replacing the -capability. - -Emulator: `wake_alarm_api36` (Pixel 6a profile, API 36). The SDK tools are at -`~/Android/Sdk` but **not on PATH** — export `ANDROID_HOME` and add -`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself. The -phone (Pixel 6a, `23181JEGR08034`) holds a **release-signed** build, so -`phone_deploy.sh` needs `--release` or the install fails with -`INSTALL_FAILED_UPDATE_INCOMPATIBLE`. - -**The ring screen must close itself on dismiss.** `AlarmActivity` handles -`closeRingScreen` (the shared `AlarmChannel` holds `applicationContext` and -cannot finish an activity). Without it the dismiss screen stays up over the -lockscreen forever — back is disabled by design in both halves — and the live -instance would carry a satisfied room proof and the solved answer into the -next ring. +`phone_app/` is no longer a time picker: it arms and rings its own alarm +(items 1/4) via five Kotlin files behind **one** MethodChannel, gating +dismissal behind a room proof plus a challenge. **Never replace that with a +pub plugin, and never move the ring off `STREAM_ALARM`.** Everything that is +a hard crash or a silent no-ring is in **`DOCS-phone-alarm.md`**. ## Module map @@ -290,9 +212,8 @@ considering a dependency change done. `[tool.pylint.typecheck]` — don't remove it if pylint starts complaining about `wave.Wave_write`. - **`wake_state.json` is runtime state, not tracked.** It holds the - HMAC-signed dismissal record for the current alarm day. It used to be - accidentally committed in the monorepo; it is gitignored here and must - stay that way. + HMAC-signed dismissal record for the current alarm day, was accidentally + committed in the monorepo, and must stay gitignored here. ## Commands @@ -305,12 +226,11 @@ considering a dependency change done. - Watch it live: `journalctl --user -fu wake-alarm-trigger.service` - Install for production: `bash install.sh` - Gate the shell/system scripts: `bash scripts/vmbox/harness.sh` - (disposable Arch VM; what it covers and what it cannot is in - **`DOCS-vmbox.md`** — notably *not* audio, the phone, or the - bootloader). A sandbox pass never proves the host is fine. + (disposable Arch VM; coverage and limits in **`DOCS-vmbox.md`** — notably + *not* audio, the phone, or the bootloader). -Note the dev venv lacks `gatelock`, so anything importing `_state` (including -`_trigger`) must be run with `/usr/bin/python3`, not `.venv/bin/python`. +The dev venv lacks `gatelock`, so anything importing `_state` (including +`_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`. ## Do NOT @@ -319,12 +239,12 @@ Note the dev venv lacks `gatelock`, so anything importing `_state` (including don't "improve" it to `requests`, and don't make the `crdt_sync` import unconditional: the `try/except ImportError` guard is what keeps a failed install from killing the trigger. -- Don't forget the orchestrator when changing this package's module path or - invocation — see "The morning_routine orchestrator lives elsewhere" above. -- Don't give `wake-alarm.service` an `[Install]` section or enable it. It - fires at login time, not alarm time — see the section above. -- Don't make the trigger start `wake-alarm.service`; it must start - `morning-routine.service` so the workout lock still runs. -- Don't raise the log level of the routine degraded-read paths in `_sync` — - the per-minute tick turns any WARNING there into journal spam. +- Don't forget the orchestrator when changing this package's module path — + see "The morning_routine orchestrator lives elsewhere" above. +- Don't give `wake-alarm.service` an `[Install]` section: it fires at login + time, not alarm time. +- Don't point the trigger at `wake-alarm.service`; it must start + `morning-routine.service`, or the workout lock never runs. +- Don't raise the log level of `_sync`'s degraded-read paths: the per-minute + tick turns any WARNING there into 1440 journal lines a day. - Don't commit `wake_state.json`. diff --git a/DOCS-phone-alarm.md b/DOCS-phone-alarm.md new file mode 100644 index 0000000..44bb005 --- /dev/null +++ b/DOCS-phone-alarm.md @@ -0,0 +1,92 @@ +# The phone half — how it rings, and what breaks it + +Split out of `AGENTS.md` to keep that file under the repo's 250-line +gate. Read this before changing anything under `phone_app/android/` or +the ring/dismiss path in `phone_app/lib/`. + +## Hand-written Kotlin, one MethodChannel + +`phone_app/` is no longer just a time picker. It arms and rings its own alarm +(items 1/4) through five Kotlin files behind **one** MethodChannel +(`AlarmChannel.kt`) on `MainActivity`/`AlarmActivity`: + +`AlarmScheduler` (arm/cancel/re-arm) · `AlarmReceiver` (fires) · +`AlarmService` (foreground service: wakelock, sound, vibration, full-screen +intent) · `AlarmActivity` (dismiss screen over the lockscreen) · +`BootReceiver` (re-arm after reboot) · `StepCounter` (item 3 fallback). + +**Never replace any of this with a pub plugin.** The stack is AGP 9.0.1 / +Gradle 9.1 / Kotlin 2.3.20; a plugin that will not compile against AGP 9 breaks +the build outright rather than degrading, and this is the one path that must +not break. Third-party plugins are acceptable only for droppable extras (QR +scanning) — never for the ring path. + +Things that are a hard crash or a silent no-ring, not a warning: + +- **`STREAM_ALARM` is load-bearing.** `AlarmService` sets + `AudioAttributes.USAGE_ALARM`, which is what ignores DND and silent mode. + Never `USAGE_MEDIA`/`STREAM_MUSIC` — it would be muted exactly when the + alarm matters most. +- **`android:foregroundServiceType="mediaPlayback"` must be on the `` + in the manifest AND passed to `startForeground()`.** Omitting either is a + hard crash the moment the alarm fires on API 34+ — and it is invisible on an + API 33 device, which is why the API 36 AVD exists. +- **`setAlarmClock()`, not `setExactAndAllowWhileIdle()`.** It is Doze-exempt, + needs no `SCHEDULE_EXACT_ALARM` prompt (`USE_EXACT_ALARM` covers an + alarm-clock app), and it is what grants the receiver its foreground-service + exemption when it fires with the screen off. +- **`FLAG_IMMUTABLE` on every `PendingIntent`** — mandatory since API 31; a + mutable one throws at arm time. +- **`canUseFullScreenIntent()` gates, it does not warn.** From API 34 the grant + can be revoked; `_armOwnAlarm()` in `home_screen.dart` refuses to report + success and tells the user to grant it, because without it the alarm posts a + notification the sleeping user never sees. +- **`min/target/compileSdk` are pinned explicitly** (28/36/36) in + `android/app/build.gradle.kts`. They used to be delegated to the Flutter + Gradle plugin, so there was no floor to reason about. minSdk 28 is what makes + `android:showWhenLocked`/`turnScreenOn` plain manifest attributes. + +**Re-arm has three hooks, and needs all three.** `LogStore` has **no change +stream** (the plan claimed one; it does not exist in `crdt_sync_dart`), so the +picker's save is only one of them: `main()`'s startup `rearm()`, +`BOOT_COMPLETED`, and the dismiss handler. Drop any one and the alarm rings +once and then silently stops. + +**The phone rings daily**, matching the PC's `ALARM_DAYS` (all seven days). +The two are deliberately not synced — a shared day set is item 8's shape, and +diverging defaults would have the halves firing on different days. + +`lib/services/challenge.dart` is a port of `_challenges.py`'s five kinds so +both halves behave identically; `kDismissCodeLength`/`kDismissFlashSeconds` +are **duplicated** from `_constants.py`, not synced. Change one, change both. + +Room proof (item 3) is `lib/services/room_proof.dart`: **NFC or QR satisfies +on its own; steps alone never does** — the phone is stationary while you walk +to it, so steps only accrue after pickup and can be faked by shaking it in +bed. Steps are accepted only together with elapsed time, as the fallback for +when both other mechanisms fail. + +**`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test +handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the +normal arm path needs a sync backend connected, so there is no other way to +fire the ring on an emulator. The probe runs the **real** app widget and +pushes `/ring` — an earlier version rendered its own UI and so proved nothing +about the route. `flutter build apk` uses `lib/main.dart`, so the probe is +never the shipped entrypoint; `ringNow` is reachable only over this app's own +channel and arms nothing. Do not "clean these up" without replacing the +capability. + +Emulator: `wake_alarm_api36` (Pixel 6a profile, API 36). The SDK tools are at +`~/Android/Sdk` but **not on PATH** — export `ANDROID_HOME` and add +`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself. The +phone (Pixel 6a, `23181JEGR08034`) holds a **release-signed** build, so +`phone_deploy.sh` needs `--release` or the install fails with +`INSTALL_FAILED_UPDATE_INCOMPATIBLE`. + +**The ring screen must close itself on dismiss.** `AlarmActivity` handles +`closeRingScreen` (the shared `AlarmChannel` holds `applicationContext` and +cannot finish an activity). Without it the dismiss screen stays up over the +lockscreen forever — back is disabled by design in both halves — and the live +instance would carry a satisfied room proof and the solved answer into the +next ring. + diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 3921fb7..ed5ecec 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/settings_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_arming.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; import 'package:wake_alarm_sync/services/firebase_backend.dart'; @@ -29,7 +29,7 @@ class _HomeScreenState extends State { AlarmSyncService? _service; /// Platform bridge that arms the app's own alarm (item 1). - final AlarmScheduler _scheduler = AlarmScheduler(); + final AlarmArming _arming = AlarmArming(AlarmScheduler()); @override void initState() { @@ -82,44 +82,6 @@ class _HomeScreenState extends State { } } - /// Opens the system Clock app pre-filled via ACTION_SET_ALARM. - /// - /// android_intent_plus v5 uses startActivityForResult; the Clock app - /// never sends a result, so we time out after 3 s rather than hang forever. - /// Arms the app's own alarm — no user interaction (item 1). - /// - /// The SET_ALARM intent below is kept only as optional redundancy in the - /// system Clock app; it is no longer what makes the phone ring, because it - /// required the user to confirm in another app every single time. - /// - /// Returns an error string when the alarm could not be armed, or null on - /// success. A full-screen-intent grant that has been revoked is a hard - /// failure, not a warning: without it the alarm posts a notification the - /// sleeping user never sees. - Future _armOwnAlarm() async { - if (!await _scheduler.canUseFullScreenIntent()) { - return 'Allow "full screen notifications" for this app in Android ' - 'settings — without it the alarm cannot show over the lockscreen.'; - } - await _scheduler.schedule(_hour, _minute); - return null; - } - - Future _setPhoneAlarm() async { - final intent = AndroidIntent( - action: 'android.intent.action.SET_ALARM', - arguments: { - 'android.intent.extra.alarm.HOUR': _hour, - 'android.intent.extra.alarm.MINUTES': _minute, - // SKIP_UI: the app already armed its own alarm, so the Clock entry is - // pure redundancy and must not demand a tap. - 'android.intent.extra.alarm.SKIP_UI': true, - 'android.intent.extra.alarm.MESSAGE': 'Wake Alarm', - }, - ); - await intent.launch().timeout(const Duration(seconds: 3), onTimeout: () {}); - } - Future _syncAndSet() async { final settings = await SyncSettings.load(); final service = _service; @@ -139,12 +101,12 @@ class _HomeScreenState extends State { await service.setLocal(_hour, _minute); await service.push(settings.token); setState(() => _status = 'Synced ✓ — arming alarm…'); - final problem = await _armOwnAlarm(); + final problem = await _arming.arm(_hour, _minute); if (problem != null) { setState(() => _status = problem); return; } - await _setPhoneAlarm(); + await _arming.registerWithClockApp(_hour, _minute); final at = _fmt(_hour, _minute); setState(() => _status = 'Done! Alarm armed for $at.'); } on Exception catch (e) { diff --git a/phone_app/lib/services/alarm_arming.dart b/phone_app/lib/services/alarm_arming.dart new file mode 100644 index 0000000..9d5ec65 --- /dev/null +++ b/phone_app/lib/services/alarm_arming.dart @@ -0,0 +1,52 @@ +import 'package:android_intent_plus/android_intent.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; + +/// Arms the phone's own alarm for a chosen time (item 1: no user interaction). +/// +/// Split out of `home_screen.dart` to keep that file under the 250-line gate, +/// and because "what it takes to arm an alarm" is its own concern from "the +/// picker UI". +class AlarmArming { + /// Creates an armer over [scheduler]. + const AlarmArming(this.scheduler); + + /// Platform bridge to the Kotlin scheduler. + final AlarmScheduler scheduler; + + /// Arms the alarm for [hour]:[minute]. + /// + /// Returns an error string when it could not be armed, or null on success. + /// A revoked full-screen-intent grant is a hard failure, not a warning: + /// without it the alarm posts a notification the sleeping user never sees, + /// so reporting success would be a lie the user only discovers at 06:00. + Future arm(int hour, int minute) async { + if (!await scheduler.canUseFullScreenIntent()) { + return 'Allow notifications and "full screen notifications" for this ' + 'app in Android settings — without them the alarm cannot show ' + 'over the lockscreen.'; + } + await scheduler.schedule(hour, minute); + return null; + } + + /// Also registers the time in the system Clock app, as pure redundancy. + /// + /// This used to be the mechanism, and required confirming in another app + /// every single time. `SKIP_UI` is true because the app has already armed + /// its own alarm by now, so this must not demand a tap. + /// + /// android_intent_plus v5 uses startActivityForResult and the Clock app + /// never sends a result, so this times out after 3s rather than hang. + Future registerWithClockApp(int hour, int minute) async { + final intent = AndroidIntent( + action: 'android.intent.action.SET_ALARM', + arguments: { + 'android.intent.extra.alarm.HOUR': hour, + 'android.intent.extra.alarm.MINUTES': minute, + 'android.intent.extra.alarm.SKIP_UI': true, + 'android.intent.extra.alarm.MESSAGE': 'Wake Alarm', + }, + ); + await intent.launch().timeout(const Duration(seconds: 3), onTimeout: () {}); + } +}