Bring the two files this change grew back under the 250-line gate

The gate runs --all-files at push time, so it caught two files this session
pushed over: AGENTS.md (244 -> 330) and home_screen.dart (238 -> 269). Both
are mine; the pre-existing Python overages are untouched, per the standing
rule not to split a file this change does not already have to edit.

- The phone section moves to DOCS-phone-alarm.md, following the repo's own
  pattern (DOCS-architecture.md, DOCS-vmbox.md), leaving a pointer.
- Alarm arming moves out of home_screen.dart into AlarmArming — "what it
  takes to arm an alarm" was never the picker's concern, and the extraction
  also repaired a doc comment my earlier edit had orphaned onto the wrong
  method.

flutter test: 69 pass. dart analyze lib/ test/: clean. APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 21:43:16 +02:00
parent 026048c7a8
commit 10a594305c
4 changed files with 168 additions and 142 deletions

120
AGENTS.md
View File

@ -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 `<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.
`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`.

92
DOCS-phone-alarm.md Normal file
View File

@ -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 `<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.

View File

@ -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<HomeScreen> {
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<HomeScreen> {
}
}
/// 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<String?> _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<void> _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<void> _syncAndSet() async {
final settings = await SyncSettings.load();
final service = _service;
@ -139,12 +101,12 @@ class _HomeScreenState extends State<HomeScreen> {
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) {

View File

@ -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<String?> 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<void> registerWithClockApp(int hour, int minute) async {
final intent = AndroidIntent(
action: 'android.intent.action.SET_ALARM',
arguments: <String, Object>{
'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: () {});
}
}