mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Trim hardening TODO to real residue
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a51a67e2fe
commit
18a304de55
@ -1,225 +1,23 @@
|
||||
# Wake-alarm hardening — continue here
|
||||
# Wake-alarm hardening — residue
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Phases 0–7 are committed (`4286fcb`..`f5eb7fc`). This file is the entry point
|
||||
for the rest. **Full plan, with every measured finding:**
|
||||
`~/.claude/plans/wake-alarm-still-does-witty-toucan.md` — read it before
|
||||
starting; this file deliberately does not duplicate it.
|
||||
Phases 0-7 shipped. The PC alarm window, gate and `_alarm.py` were deleted in
|
||||
`e67cef0` (2026-08-30), so the old "item 8 mutual gating" (PC half) no longer
|
||||
has anything to gate and was dropped. Context: `DOCS-wake-enforcement.md`.
|
||||
|
||||
## Done so far
|
||||
## Remaining
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| 6 audio (HDMI silent) | **Done.** `_reactivate` wired into `WakeAlarm`; verified on the host — the HDMI sink appeared and became default. |
|
||||
| 7 speakers | `DOCS-speaker-recommendation.md`. Prose only, done. |
|
||||
| 9 refactor | `_alarm.py` 478→250. 12 new modules; see `DOCS-architecture.md`. |
|
||||
| 11 failsafe | `FAILSAFE_SILENCE_SECONDS=300`, audio only. Escalation re-tuned to fit inside it. |
|
||||
| 12 logging | JSONL + cross-process dedupe + derived correlation id. |
|
||||
| 14 fans | Gone: code, script, sudoers entry, installed state. Three stale doc/message references cleaned up in phase 6. |
|
||||
| 2 power button | **Done.** `_inhibit.py` (logind block, self-terminating, stale cleanup by pid via `/proc`) + `_alarm_session.py` fire-time record, re-fired by `_trigger` outside the catch-up window. |
|
||||
| 10 vmbox | **Done.** `scripts/vmbox/harness.sh` + `DOCS-vmbox.md`. 8 cases, fails closed. Found 2 live bugs the host could not reach. |
|
||||
| 5 curtains | **Done.** `_curtains.py` driver protocol + no-op default, called from `prepare_hardware()`. `DOCS-curtains-hardware.md` has the buy list. |
|
||||
| 1/4 phone alarm | **Done, verified on the phone.** Five Kotlin files behind one MethodChannel + the Dart ring screen. Fires from Doze on time, wakes the screen over the lockscreen, rings through DND on STREAM_ALARM, and dismisses via an NFC tap + challenge. Confirmed by the user on the Pixel 6a, not inferred. |
|
||||
| 3 room proofs | **Mostly done.** NFC works end to end: `NfcReader.kt` (hand-written), Settings → "Room proof" registers the tag, and a tap satisfies the gate. Steps+elapsed is the fallback. QR is specified, persisted and unit-tested but has **no scanner UI** — `mobile_scanner` is the droppable extra. |
|
||||
1. **Item 13, black screen.** Host-only, other repo
|
||||
(`~/src/testsAndMisc/linux_configuration`). The original plan file
|
||||
(`~/.claude/plans/wake-alarm-still-does-witty-toucan.md`) is gone, so the
|
||||
symptom is no longer written down: needs kuhy to restate it, or drop it.
|
||||
2. **Two-tag dismiss sequence, end to end on the Pixel.** Needs kuhy: a second
|
||||
physical NFC tag (only one is registered, so every on-device run fell
|
||||
through to the 5-minute bypass).
|
||||
3. **Locked-keyguard dismiss, run to completion.** Needs kuhy: a run with a
|
||||
locked phone (PIN-prompt path is implemented but unproven).
|
||||
|
||||
## Remaining, in order
|
||||
## done
|
||||
|
||||
1. ~~QR scanner UI~~ — **cancelled 2026-08-30.** QR was deleted rather than
|
||||
finished: it had never been reachable at ring time (no scanner package, no
|
||||
CAMERA permission, `onQrScanned` had no caller), and a printed code is
|
||||
photographable, so completing it would have added a bypass to the very gate
|
||||
the same change was hardening. The dismiss path is now a strict sequence:
|
||||
tag 1 → 25 steps + 2 min → tag 2 → 3 challenges.
|
||||
2. **Item 8, mutual gating.** Phone half is now in place. This is the phase
|
||||
that legitimately touches `_sync.py`, so it carries that split (see below).
|
||||
3. **Item 13, black screen.** Different repo (`~/src/testsAndMisc/
|
||||
linux_configuration`), host-only, blocks nothing.
|
||||
|
||||
### Verified end to end on the Pixel 6a, 2026-08-27
|
||||
|
||||
**The whole path is confirmed on hardware, by the user, not inferred:**
|
||||
|
||||
- Fires from Doze on time (22:03:01 for a 22:03 arm), registered by the system
|
||||
as `RTC_WAKEUP` / "Next wake from idle".
|
||||
- Wakes the screen (`Asleep`→`Awake`) and puts `AlarmActivity` on top over the
|
||||
lockscreen, with the full-screen-intent notification posted.
|
||||
- **Rings through DND** — `zen_mode=1`, player `state:started` on
|
||||
`usage=USAGE_ALARM`/`STREAM_ALARM(4)`, `mutedState:none`. The STREAM_ALARM
|
||||
claim is measured, no longer assumed.
|
||||
- NFC tag registers (Settings → Room proof) and **a tap dismisses**:
|
||||
`tag discovered: 0465dab3ce2a81` on the ring screen, challenge solved,
|
||||
alarm silent.
|
||||
- Step counter reaches the gate.
|
||||
|
||||
**Four defects were found only because it was run on the phone**, every one
|
||||
invisible to the emulator (no NFC, no step sensor) and to the widget tests
|
||||
(which mock the channel):
|
||||
|
||||
1. `AlarmActivity.onPause` called `NfcReader.stop()`. `enableReaderMode` is
|
||||
per-activity but `disableReaderMode` is global, so anything briefly in
|
||||
front of the ring screen killed polling for the rest of the ring.
|
||||
2. `TYPE_STEP_COUNTER` is cumulative and on-change: it emits nothing until the
|
||||
user has already walked, and the first emission was being eaten as the
|
||||
baseline, so the screen sat at `0/25` forever. `TYPE_STEP_DETECTOR` is now
|
||||
primary — one event per stride, no baseline to miss.
|
||||
3. NFC does not poll while the ring shows over a locked keyguard
|
||||
(`mScreenState=ON_LOCKED`). `requestDismissKeyguard()` is called on fire.
|
||||
It does **not** silently unlock a secured phone — it raises the PIN prompt
|
||||
— so "tap without touching the phone" was never achievable on a secured
|
||||
device. Say that plainly; don't let docs imply otherwise.
|
||||
4. The **flash** challenge hides its code after 4s, which is unsolvable when
|
||||
the screen is reached by walking to another room. Removed from the phone's
|
||||
rotation, with a test asserting no self-hiding challenge is ever shown.
|
||||
|
||||
**A process lesson worth more than any of them:** after hardware confirmed the
|
||||
path worked, the next change bundled a MethodChannel restructure with a bug
|
||||
fix and regressed both NFC and steps. The user caught it. When hardware has
|
||||
just gone green, make the smallest possible delta on top of *that exact state*
|
||||
— never a restructure and a fix together.
|
||||
|
||||
### Fully automatic, verified on the Pixel 2026-08-27
|
||||
|
||||
The alarm is no longer opt-in and can no longer be weakened:
|
||||
|
||||
- **Arms itself with zero taps.** Launching the app was enough —
|
||||
`dumpsys alarm` showed `RTC_WAKEUP ... com.kuhy.wake_alarm_sync/.AlarmReceiver`
|
||||
at `2026-08-28 08:00:00`, `window=0`, `exactAllowReason=policy_permission`.
|
||||
- **08:00 daily is baked in** (`AlarmPolicy.baselineHour`, mirrored by the PC's
|
||||
`FALLBACK_ALARM_HOUR`). There is no arm button and no cancel — the Kotlin
|
||||
`cancel` handler is deleted, so the capability does not exist rather than
|
||||
being one `invokeMethod` away.
|
||||
- **Earlier-only ratchet, confirmed on-device.** Picking a later time was
|
||||
rejected on the phone with "That is not earlier than 08:00. The alarm can
|
||||
only be moved earlier.", and the armed alarm stayed at 08:00. An earlier
|
||||
start is one-shot and resets after the alarm fires.
|
||||
- **Daily watchdog** (`AlarmWatchdog`) re-arms if nothing is scheduled, and
|
||||
`BootReceiver` re-establishes it because `setInexactRepeating` does not
|
||||
survive a reboot.
|
||||
- **PC watchdog:** `_trigger._clamp_to_baseline()` refuses any resolved alarm
|
||||
later than 08:00 whatever its source, so a stale or tampered remote value
|
||||
cannot move the alarm later through sync.
|
||||
|
||||
### DND: verified for priority mode, NOT for total silence
|
||||
|
||||
Measured on the Pixel: `STREAM_ALARM` is unmuted under **priority** DND
|
||||
(`zen_mode=1`, mask `0x1a6`) and **muted** under **total silence**
|
||||
(`zen_mode=2`, mask `0x9be`). The confirmed ring was priority DND. No audio
|
||||
flag escapes total silence, so if that gap matters the fix is a zen-policy
|
||||
check at arm time, not an audio change. Detail in `DOCS-phone-alarm.md`.
|
||||
|
||||
Two harness traps found while testing this, worth keeping:
|
||||
|
||||
- **Do not pass `--build-number` to a probe build** — it produced a
|
||||
non-running APK.
|
||||
- **The arm probe can register for TOMORROW.** It calls `schedule()`, then
|
||||
`main.dart`'s `rearm()` recomputes `nextOccurrence`; if the armed minute has
|
||||
passed by then the alarm lands on the next day. Always re-check the
|
||||
registered `triggerTime` is today before waiting for a ring.
|
||||
|
||||
### Still unverified
|
||||
|
||||
- The **two-tag sequence** has not been walked end to end on the phone: only
|
||||
one physical tag is registered, so every on-device run so far has fallen
|
||||
through to the 5-minute bypass. Needs a second tag.
|
||||
- The **locked-keyguard** dismiss was not run to completion: the confirmed run
|
||||
was on an unlocked phone. The PIN-prompt path is implemented but unproven.
|
||||
|
||||
## Decisions already made — do not re-litigate
|
||||
|
||||
- Failsafe stops **sound only**; lock, challenge and workout gate persist.
|
||||
- Gating is **phone first, then PC**, with a bounded escape: if sync is
|
||||
unreachable for ~600 s the PC gate degrades to locally-solvable, logged
|
||||
WARNING. Without that, a Firebase outage means you can neither dismiss nor
|
||||
get to work.
|
||||
- Room proof: NFC **or** QR qualifies. **Steps alone does not** — the phone is
|
||||
stationary in the other room while you walk *to* it, so steps only accrue
|
||||
after pickup and can be faked by shaking it in bed. Steps + elapsed time is
|
||||
the fallback when both others fail. (I narrowed "any one satisfies" here;
|
||||
say if you disagree.)
|
||||
- Phone alarm: hand-written Kotlin behind one MethodChannel, **not** a plugin.
|
||||
AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that
|
||||
won't compile breaks the build outright. `STREAM_ALARM` is load-bearing —
|
||||
it is what ignores DND and silent mode.
|
||||
- Curtains: no hardware; pluggable driver + logging no-op default.
|
||||
- Quality bar: 100% branch coverage, no new `# noqa` / `type: ignore`.
|
||||
|
||||
## Traps found in the phone session
|
||||
|
||||
- **The plan's `LogStore.changes` does not exist.** `crdt_sync_dart` has no
|
||||
Stream at all. Re-arm therefore needs three explicit hooks — startup,
|
||||
BOOT_COMPLETED, dismiss — and dropping any one makes the alarm ring once
|
||||
and then silently stop forever.
|
||||
- **The Android SDK was never missing, only unexported.** `~/sdk/Android/Sdk` has
|
||||
`cmdline-tools`, `emulator` and `platform-tools`; only `adb` was on PATH.
|
||||
Export `ANDROID_HOME` rather than installing anything.
|
||||
- **A permission declared in the manifest is not a permission granted.**
|
||||
Both `POST_NOTIFICATIONS` and `ACTIVITY_RECOGNITION` are runtime grants, and
|
||||
each one missing silently breaks a different half of the dismiss path
|
||||
(no full-screen intent / no step sensor). The AVD caught both because a
|
||||
freshly installed app there sits at `importance=NONE`.
|
||||
- **A debug probe entrypoint with its own UI proves nothing about the route.**
|
||||
The first probe rendered its own widget, so the AVD run validated the Kotlin
|
||||
and nothing about `/ring`. Point the probe at the real app widget.
|
||||
|
||||
## Traps that cost time this session
|
||||
|
||||
- **The 250-line gate fires on *staged* files.** Touching a file means
|
||||
splitting it in the same commit. Still over (re-measured 2026-08-27, after
|
||||
phase 6): `test_sync.py` 386, `_sync.py` 371, `test_smart_plug.py` 351,
|
||||
`_sinks.py` 341, `_alarm_view.py` 329, `_escape_flow.py` 317,
|
||||
`test_state.py` 314. Budget for it. `test_trigger.py` is now split and
|
||||
under.
|
||||
- **`morning_routine/_orchestrator.py:39` hardcodes `"wake_alarm._alarm"`**
|
||||
(different repo, `~/src/testsAndMisc`). Any module-path change updates it in the
|
||||
*same* commit. This exact omission caused a month-long silent outage. Keep
|
||||
`main()` in `_alarm.py`.
|
||||
- **Splitting a test file loses its fixtures.** `_make_mock_tk`,
|
||||
`_block_real_tk` and `_block_extra_devices` live *after* the imports, so a
|
||||
naive header-copy drops them and every test errors. Copy the whole region
|
||||
before the first `class`. Also: a multi-line module docstring gets stranded
|
||||
if you replace only its first line.
|
||||
- **Moving a symbol breaks `patch("wake_alarm._alarm.X")` silently** — the
|
||||
patch stops intercepting and the test asserts nothing. Grep the whole
|
||||
`tests/` tree for the old target after any move.
|
||||
- **Autoformat strips an import added before its first usage.** `struct`
|
||||
vanished from the extracted `_tone.py`; ruff, mypy and pylint all passed.
|
||||
Only running it caught the `NameError`. Add import and usage in one edit.
|
||||
- **Never let a beep-loop test miss its rung.** A test that patches only one
|
||||
of `_beep_soft/_medium/_loud` and waits for it will spin forever on the real
|
||||
audio stack and hang the whole suite instead of failing. Patch all three.
|
||||
- Run tests with `/usr/bin/python3`, not the dev venv (no `gatelock` there).
|
||||
- **`vm run` does not propagate the guest command's exit code** — it reports
|
||||
the VM's verdict. A gate built on it fails *open*. See `DOCS-vmbox.md`.
|
||||
- **`_state.py`'s UTC `_today_str()` must NOT be flipped to local**, despite
|
||||
what the plan says. `screen_locker/_wake_state.py` mirrors the helper and
|
||||
reads the same `wake_state.json`; flipping it loses an earned workout skip
|
||||
for any dismissal between 00:00 and 02:00 local. `_alarm_session.py` shows
|
||||
the way around it: carry an aware `fired_at`, compare elapsed time, no date
|
||||
key at all.
|
||||
- **Do not split a file the current change does not already have to touch.**
|
||||
The gate fires on *staged* files, so an oversized file only becomes your
|
||||
problem once you edit it. Editing `_sync.py` merely to repoint an import
|
||||
pulled its 371 lines into the changeset and cascaded into five new modules
|
||||
and a broken tree before being reverted. Prefer putting a new constant in
|
||||
its single consumer (as `_curtains.py` does) over touching `_constants.py`.
|
||||
- **A `__main__` CLI extracted to a new module breaks its documented command
|
||||
silently.** `python -m wake_alarm._sync --show` printed nothing and exited
|
||||
0 — invisible to ruff, mypy, pylint and the suite alike. If a later phase
|
||||
moves that CLI, run the documented command and check the *output*.
|
||||
- **Ruff's autofix will gut a function** whose results are only printed —
|
||||
it stripped both the assignments and the `print` from a vmbox check, and
|
||||
every linter stayed green. Grep the file for its output call after any
|
||||
autofix.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
/usr/bin/python3 -m pytest wake_alarm/tests/ -q # 455 pass, 100% cov
|
||||
bash scripts/vmbox/harness.sh # 8/8 cases, exit 0
|
||||
pre-commit run --files <changed>
|
||||
/usr/bin/python3 -m wake_alarm._trigger --dry-run
|
||||
/usr/bin/python3 -m wake_alarm._alarm --demo # real window; ask first
|
||||
```
|
||||
|
||||
Done = every item above shipped, this file deleted.
|
||||
Items 1-3 each confirmed on the phone or dropped by kuhy; then delete this file.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user