wake-alarm/DOCS-phone-alarm.md
Krzysztof kuhy Rudnicki 867ff8da22 Qualify the DND claim: priority DND yes, total silence no
Measured on the Pixel via the muted-stream mask in `dumpsys audio`:
STREAM_ALARM is unmuted under priority DND (zen_mode=1, mask 0x1a6) and
MUTED under total silence (zen_mode=2, mask 0x9be). The verified ring was
priority DND, so "STREAM_ALARM ignores DND" was true but overstated — no
audio flag escapes total silence, and the fix if that gap ever matters is a
zen-policy check at arm time, not an audio change.

Also records that raiseAlarmVolume() genuinely runs on this hardware
(observed raising oldIndex:0 -> 7, no SecurityException), so it recovers a
stream turned down to zero but not one muted by total silence.

Two probe-harness traps kept: --build-number produces a non-running APK, and
the arm probe can register for TOMORROW because main.dart's rearm()
recomputes nextOccurrence after the armed minute has passed.

Folded in from scratch notes left mid-session (.dnd-findings/,
TODO-verify-dnd-ring.md), both now deleted — the verification they asked for
has since been done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:07:57 +02:00

6.0 KiB

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 — but it does not beat every DND mode. AlarmService sets AudioAttributes.USAGE_ALARM; never USAGE_MEDIA/STREAM_MUSIC, which would be muted exactly when the alarm matters most. Measured on the Pixel 6a (2026-08-27), the muted-stream mask from dumpsys audio differs by zen mode:

    DND mode zen_mode STREAM_ALARM
    priority (ZEN_MODE_IMPORTANT_INTERRUPTIONS) 1 not muted (mask 0x1a6)
    total silence (ZEN_MODE_NO_INTERRUPTIONS) 2 MUTED (mask 0x9be)

    The confirmed ring was under priority DND (zen_mode=1): player state:started, usage=USAGE_ALARM → STREAM_ALARM(4), mutedState:none. Total silence mutes the alarm stream system-wide, and no audio flag escapes it — so "ignores DND" means priority DND, not total silence. If that gap ever matters, the fix is a zen-policy check at arm time, not an audio change. raiseAlarmVolume() does run on this hardware (observed raising oldIndex:0 → 7, no SecurityException), which recovers a stream turned down to zero but not one muted by total silence.

  • android:foregroundServiceType="mediaPlayback" must be on the <service> 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.