mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:47:31 +01:00
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:
parent
026048c7a8
commit
10a594305c
120
AGENTS.md
120
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 `<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
92
DOCS-phone-alarm.md
Normal 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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
52
phone_app/lib/services/alarm_arming.dart
Normal file
52
phone_app/lib/services/alarm_arming.dart
Normal 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: () {});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user