Commit Graph

11 Commits

Author SHA1 Message Date
150a8cd7e5 Require two tags, the walk, and three answers to dismiss
The dismiss gate was an OR — NFC tag *or* QR *or* (25 steps + 2 min) —
followed by one challenge. QR never worked at ring time (no scanner
package, no CAMERA permission, `onQrScanned` had no caller), so the real
bar was "walk 25 steps, wait 2 minutes, answer one question" and the NFC
tag was already fully bypassable.

It is now a strict, latched sequence owned by `DismissGate`:

    tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges

Every stage is required, in that order, and a stage once passed never
reverts. That last part also fixes a real bug: `_onNfcTag` and `_trySteps`
both assigned the room-proof result straight onto `_inRoom`, so a wrong
tag tapped after the gate had passed sent the user back to the start.

Two *distinct* tags are required; the same id in both slots is refused at
registration, since one tag satisfying both stages removes the walk that
is the whole point. The existing single-tag registration migrates into
slot 1 rather than being stranded.

Answers are cumulative, not consecutive: a wrong one costs the attempt and
nothing else.

The bypass is the only fallback and is deliberately narrow. After 5 minutes
of ringing, or immediately when NFC is off or fewer than two distinct tags
are registered, the two *tag* stages are skipped and the count rises to 6.
The walk is never skipped — jumping straight to the challenges would let
someone lie in bed for five minutes and answer questions without standing
up, which is weaker than the gate this replaces. Without some such hatch a
dead tag at 06:00 leaves an alarm that cannot be switched off at all.

Also deletes the `flash` challenge outright, along with the now-orphaned
`generateCode`/`kDismissCodeLength`/`_dismissChars`. It was the only kind
that removed its prompt from the screen, which is unsolvable for someone
who has just walked in from another room.

The ring screen takes an injectable clock: `Stopwatch` reads the wall
clock, which `tester.pump()` does not advance, so without that seam no
widget test could reach the two-minute floor or the five-minute bypass.

Verified on the Pixel 6a: the existing tag migrated into slot 1 and both
rows render. 102 Dart tests pass; analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:09:00 +02:00
95be57b575 Make the alarm fully automatic and un-weakenable (08:00, earlier-only)
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.

- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
  offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
  after the baseline, and refuses to relax an active override. Earlier-only is
  the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
  ratchet with no reset is a trap, not a defence — the first 05:00 would
  otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
  baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
  capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
  self-rescheduling check that re-arms if nothing is scheduled — closing the
  gap where a force-stop or a failed re-arm goes unnoticed until the morning
  it does not ring. BootReceiver re-establishes it, since setInexactRepeating
  does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.

PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
  Firebase outage silently moved the alarm five hours LATER — the one
  direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
  baseline, whatever its source. Without it the PC would faithfully reproduce
  a stale or tampered remote value and the forbidden direction stays reachable
  through sync.

Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.

`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:03:00 +02:00
a845cac883 Remove the flash challenge; revert the MethodChannel split that regressed NFC
Two things, one of them an undo of my own change.

The flash challenge is gone from the phone's rotation. It hides the code after
kDismissFlashSeconds, and on the phone that is unsolvable in practice: the ring
screen is reached by walking to another room and tapping a tag, so the code has
already vanished by the time the user is looking at it. Reported from the phone
as "invisible - impossible to actually do". Every challenge the alarm shows now
stays on screen until it is answered, and a test asserts next() never returns
one that hides itself. The constructor stays for the PC half's parity tests;
nothing routes to it.

The two-channel split is reverted. I introduced it to fix a tag reaching Kotlin
but not Dart, and pointed the push channel at the call channel — which broke
NFC *and* the step counter, both of which had been confirmed working on the
phone one build earlier. The user caught it: "they worked previously so your
changes regressed it". The split was a theory that was never shown to be
necessary; the single channel from 08b44b4 is restored unchanged.

Lesson worth keeping: when hardware has just confirmed a path works, the next
change should be the smallest possible delta on top of that exact state, not a
restructure bundled with a fix.

flutter test: 70 pass. dart analyze lib/ test/: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 22:45:35 +02:00
dc2d54668b Phase 4/3: close the ring screen on dismiss, and make NFC registerable
Two defects that each defeated the alarm in a different way:

1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
   the alarm re-armed, but the dismiss screen stayed up over the lockscreen
   forever — back is disabled in both halves by design — and the live instance
   kept _inRoom=true with the solved answer still typed, so the next ring
   could reuse an already-passed gate. AlarmActivity now handles
   closeRingScreen; the shared AlarmChannel deliberately holds
   applicationContext and cannot finish an activity, hence the split handler.

2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
   checkNfc always answered "no tag registered" and checkQr was never called.
   Functionally that left steps as the only satisfiable proof, which is
   exactly the state the decision rules out ("NFC or QR qualifies; steps alone
   does NOT") and removes the redundancy whose purpose is that one failing
   mechanism at 06:00 must never block dismissal. Added RoomProofScreen
   (reader mode from MainActivity, since registration needs an Activity) and
   linked it from Settings.

Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.

New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.

Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.

flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:39:01 +02:00
cb50e58a56 Phase 4/3: NFC room proof, runtime permissions, non-blocking step reads
Three defects found by reviewing the emulator run, all on the dismiss path:

1. ACTIVITY_RECOGNITION was declared but never requested at runtime (API 29+).
   Denied, TYPE_STEP_COUNTER delivers nothing, so checkSteps() reports "sensor
   unavailable" forever. With steps the only wired proof, that made the alarm
   *undismissable*, not merely degraded. MainActivity now requests it
   alongside POST_NOTIFICATIONS.

2. checkNfc/checkQr were dead code — no reader, no channel method, no
   registration — so steps was the only reachable proof. That inverts the
   decision ("NFC or QR qualifies; steps alone does NOT") and removes exactly
   the redundancy whose purpose is that one failing mechanism at 06:00 must
   never block dismissal. Added NfcReader (hand-written on the existing
   channel, NfcAdapter.enableReaderMode so the tap does not restart the
   activity and reset the challenge) plus RoomProofSettings for the registered
   tag/QR. mobile_scanner stays the droppable extra.

3. StepCounter blocked the platform main thread up to 3s per read, called from
   the MethodChannel handler — jank-to-ANR on the ring screen. Now registers a
   listener and serves the last sample; reads never block.

Also: raiseAlarmVolume() is guarded. setStreamVolume can throw under a zen
rule, and by then startForeground() has already posted — a failed volume bump
must not take the ring down with it.

Verified on the API 36 AVD with the real app widget and route (the earlier
probe rendered its own UI, so it proved the Kotlin path but nothing about
/ring): screen Asleep -> Awake, AlarmActivity top resumed over the lockscreen,
RingScreen showing the room-proof gate. New widget tests cover the /ring route
resolving and the challenge staying gated behind the room proof.

flutter test: 57 pass. dart analyze: clean. Zero Python files touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:28:24 +02:00
af2a9b416b Phase 4/3: the phone's Dart half — ring screen, challenges, room proofs
Dart side of the alarm (items 1, 4, 3). main.dart had no init seam at all;
it now has ensureInitialized(), a navigatorKey and a /ring route.

- challenge.dart: all five kinds ported from _challenges.py so both halves
  behave identically. kDismissCodeLength/kDismissFlashSeconds are duplicated
  from _constants.py, not synced — noted in the file, since Dart cannot read
  Python.
- alarm_scheduler.dart: the Dart side of the single MethodChannel.
- ring_screen.dart: room proof first, then a challenge, then dismiss. Back is
  a no-op (PopScope) so the alarm cannot be escaped.
- room_proof.dart: NFC or QR satisfies on its own; steps NEVER do alone —
  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 count only together
  with elapsed time, as the fallback when both others fail.
- home_screen: arms the app's own alarm instead of asking the user to confirm
  in the Clock app (item 1's "no user interaction"). SET_ALARM stays as
  redundancy with SKIP_UI=true.

Re-arm has three hooks and needs all three: startup, BOOT_COMPLETED, and
dismiss. The plan assumed LogStore.changes would cover this; that stream does
not exist in crdt_sync_dart (verified — the library has no Stream at all), so
the picker's save is only one hook and a restart would otherwise arm nothing.

Fixed a real gap the API 36 AVD exposed: POST_NOTIFICATIONS was never
requested at runtime, so a fresh install sat at importance=NONE and the
full-screen intent was rejected outright — the alarm would ring with no
dismiss screen. MainActivity now requests it, and canUseFullScreenIntent()
also checks areNotificationsEnabled() so the check gates rather than warns.

Verified on the API 36 AVD: screen Asleep -> Awake, AlarmActivity became the
top resumed activity over the lockscreen, service isForeground=true
types=0x2 (mediaPlayback) category=alarm, no crash. flutter test: 54 pass.
dart analyze: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:22:07 +02:00
90a7dafb48 phone_app: migrate Sync settings to the shared sync_settings_ui package
Settings screen becomes a thin dispatcher with two links: "Sync settings"
(the shared sync_settings_ui package, Firebase-only) and "Advanced sync
(GitHub)" (app-local, moved verbatim -- the shared package carries no
GitHub surface).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:29:32 +02:00
37a7829757 phone_app: add Google sign-in support for the Firebase sync account
Same pattern as todo, diet_guard_app, and workout_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel dependence),
isFirebaseConfigured() reading the stored session rather than the account
marker alone, and the settings screen offering "Sign in with Google" ahead
of the password form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:26:33 +02:00
03392e7ea5 Discover alarm device directories instead of reading a fixed phone path
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json,
and never parsed the HLC. The phone's device id is migrating from the fixed
constant "phone" to a per-install uuid (a role constant collides as soon as
a second install takes the same role, and a reinstall inherits the previous
install's CRDT identity). A reader pinned to the old path would have stopped
seeing the alarm the moment the phone migrated, fallen through to the cache
and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently.

So the PC half ships first, and this commit is that half plus the phone
change it makes safe:

- _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's
  alarm.json, and picks the newest by the hour field's HLC rather than by
  listing order. device_id breaks an exact tie so two devices stamped in the
  same millisecond don't alternate between ticks; a device whose payload
  doesn't parse is skipped rather than allowed to win with a bad value.
- SYNC_ALARM_PATH stays as a last-resort read for a backend that lists
  nothing, so an un-migrated install keeps working. The GitHub mirror still
  reads the single fixed path.
- The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key
  todo and home_inventory already use) and pushes under it. deviceId is now
  an instance field, injectable so tests don't touch real prefs.

Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's
500-line cap, and the wire format deserves testing without network or disk --
a misparse here doesn't raise, it just resolves to the wrong wake-up time.

Verified against the live RTDB, not just mocks: seeded a uuid-named device
directory and confirmed the reader discovered it, read it and ranked it --
losing to the phone with an old stamp, winning with a new one (03:15 vs the
real 07:45). Test device deleted; --show returns 07:45 and the cache is
intact.

354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 17:06:43 +02:00
512fdd5c95 Migrate phone_app alarm sync from GitHub Gist to crdt_sync
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).

shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.

Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
2026-07-11 22:45:14 +02:00
0704e44912 Add wake_alarm_sync phone-side Flutter companion app
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
New Flutter app (com.kuhy.wake_alarm_sync) that sets the phone alarm and
writes the wake time to a GitHub Gist for cross-device sync: Gist service,
GitHub device-flow auth, sync settings, home/settings screens. Source only;
build/.dart_tool/.idea artifacts excluded by the app's .gitignore.

Not yet gated by CI (wake-alarm CI is Python-only) and pending on-device
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-10 23:16:11 +02:00