The PC has been firing the morning routine at 07:00 instead of 08:00, off a
record the phone published on 2026-08-09 and never superseded. The phone was
connected to Firebase the whole time and the home screen still said "Connect a
sync backend in Settings first."
initState fired three futures concurrently and the last one read what the
first two were still writing:
unawaited(_applyPolicy()); // sets _hour/_minute
unawaited(_loadSyncState()); // sets _service
unawaited(_syncToPc()); // reads BOTH
`publish` takes the service as a *parameter*, so it captured `_service` by
value while it was still null. Assigning it moments later could never reach
the call. The startup publish therefore passed null every time and bailed out
before touching the network -- and `_syncToPc` had exactly one call site, that
one, so nothing published, ever. `_applyPolicy` has the same shape: a publish
that did win the race would have sent the baseline instead of an active
override. The bootstrap is now strictly sequential.
Three things conspired to hide it for three weeks:
- `publish` returned "Connect a sync backend in Settings first." for BOTH a
missing backend and a null service, so a broken store looked like a setup
step the user had never done. The advice pointed at something that was not
broken. The two causes now report differently, with a regression test.
- `_loadSyncState` swallowed its exception with `on Exception catch (_)`, so
if the store genuinely failed to open there was no trace anywhere. It logs
now.
- `_pickEarlier` never published at all, so "Wake me earlier tomorrow" -- the
one schedule edit the app offers -- never reached the PC either. It does now.
home_screen.dart went over the 250-line cap, so its body moves to
`screens/home_body.dart` as a stateless widget taking plain values. The screen
keeps the state and the ordering rules, which is where the bugs lived.
Verified on the Pixel 6a: home screen now reads "Synced to PC ✓", and the PC's
`_sync --show --no-cache` returns `08:00 (remote)` stamped seconds earlier,
against `07:00` from 2026-08-09 before. The phone had also minted a new device
id, so both records exist in Firebase and the HLC ranking picks the newer --
which is exactly why that reader ranks by HLC and not by listing order.
104 Dart tests pass; analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
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
**The two shared Python libraries were four minor versions behind and the gate
could not see it.** They are PEP 508 direct references
(`gatelock @ git+...@gatelock-v0.4.1`), which parsed as unpinned PyPI packages
that do not exist on PyPI -- an undeterminable answer, not a stale one.
utils@7a682a4 teaches the gate that shape; this repo is the first to benefit:
gatelock v0.4.1 -> v0.7.2, crdt-sync v0.5.1 -> v0.9.1. The system install was
already on 0.7.1/0.9.0, so only the manifests were adrift.
Python side: bandit 1.7 -> 1.9.4, mypy 1 -> 2.3.1, pylint 3 -> 4.0.7,
pytest 8 -> 9.1.1, pytest-cov 4 -> 7.1.0, pytest-randomly 3 -> 4.1.0,
ruff 0.8 -> 0.16.5, python-kasa 0.7 -> 0.10.2, and the rest exact-pinned.
Five majors and not one lint or type finding: `scripts/ci_mirror.sh` builds a
clean venv from requirements.txt and runs the whole CI set, 465 tests at 100%
branch coverage.
phone_app takes crdt_sync_dart v0.11.1 and the flutter_secure_storage 11 chain
(compileSdk 37 / AGP 9.3.2 / Gradle 9.7.1). 79 tests green, analyzer clean,
release APK builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
_sinks.py 341 -> 249: the pactl *parsing* moves to _sink_parsing.py. That half
is pure text handling with no subprocess and no side effects, so it is
testable without touching the host's audio; _sinks.py keeps command execution
and the activate/restore orchestration.
The parsers are re-imported into _sinks under their original names, guarded by
__all__. The suite patches `wake_alarm._sinks._parse_cards` and friends, and a
moved symbol makes patch() stop intercepting *silently* — the test then
asserts nothing and still passes. Ruff proved the point mid-split: shortening
__all__ let it delete two of the re-exports and broke an import outright.
_parse_profile_line is only imported (never patched), so its test now imports
from the new module instead.
home_screen.dart 270 -> 249. This one was mine — the automatic-alarm change
pushed it over and I claimed in TODO-push-and-gate.md that nothing this
session touched was over the limit; that was wrong. Publishing moves to
AlarmPublisher, which returns a status line instead of calling setState, so it
is testable without a widget. _loadSavedTime became _loadSyncState: it no
longer loads a *time*, because the schedule is baked in and owned by
AlarmPolicy, so a stored value could only disagree with it.
465 Python at 100% coverage, 79 Dart, APK builds, and both documented CLIs
still print real output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
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
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
All three were invisible to the emulator (no NFC, no step sensor) and to the
widget tests (which mock the platform channel), and each one on its own made
the alarm harder or impossible to dismiss.
1. AlarmActivity.onPause called NfcReader.stop(). enableReaderMode is
per-activity but disableReaderMode is effectively global, so anything
briefly coming in front of the ring screen killed NFC polling for the rest
of the ring. Measured on the Pixel: `flags: 271` immediately followed by
`flags: 0`. Teardown moves to onDestroy, and onResume re-asserts.
2. StepCounter could never report progress from a standing start.
TYPE_STEP_COUNTER is an on-change *cumulative* sensor: it emits nothing
until the user has already walked, and the first emission was being
consumed as the baseline — so the ring screen sat at 0/25 no matter how far
you walked. TYPE_STEP_DETECTOR is now primary (one event per stride, no
baseline to miss); the counter stays as the fallback for devices without a
detector.
3. NFC cannot be read while the ring shows over the lockscreen. Measured
A/B on the Pixel with the same tag: registration while unlocked →
mScreenState=ON_UNLOCKED, tag discovered; ring screen over the keyguard →
mScreenState=ON_LOCKED, reader mode accepted (flags 271) but no discovery.
Android gates NFC polling on lock state. showWhenLocked draws *over* the
keyguard without unlocking, so the alarm now calls
requestDismissKeyguard() on fire and re-asserts reader mode from the
dismiss callback. User's call, taken explicitly: on a secured phone this
surfaces the PIN prompt, which is an acceptable trade for an alarm you
must get up for.
Without (3) the only satisfiable proof at 06:00 is steps, which is exactly
the single-mechanism dependency the room-proof redundancy exists to prevent.
flutter test: 69 pass. dart analyze lib/ test/: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
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
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
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
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
Hand-written Kotlin behind ONE MethodChannel, never a plugin: AGP 9.0.1 /
Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that will not compile
breaks the build outright rather than degrading.
- AlarmScheduler: setAlarmClock() (Doze-exempt, no SCHEDULE_EXACT_ALARM
prompt), persists the time so boot and dismiss can re-arm.
- AlarmReceiver: hands straight to the service — a receiver gets ~10s and no
wakelock, so nothing that must outlive it belongs there.
- AlarmService: foreground service, PARTIAL_WAKE_LOCK, USAGE_ALARM
(= STREAM_ALARM, which is what ignores DND and silent mode), looping
ringtone, vibration, full-screen intent over the lockscreen.
- AlarmActivity: showWhenLocked + turnScreenOn, routes to the Dart ring
screen, back is a no-op so the alarm cannot be escaped.
- BootReceiver: re-arms on BOOT_COMPLETED; AlarmManager drops alarms across a
reboot.
- StepCounter: TYPE_STEP_COUNTER baseline for item 3's fallback proof, also
hand-written to keep plugin risk off this path.
Details that are a hard crash or a silent no-ring rather than a warning:
FLAG_IMMUTABLE on every PendingIntent (mandatory since API 31);
foregroundServiceType declared on the <service> AND passed to
startForeground(); explicit android:exported on every component;
USE_FULL_SCREEN_INTENT declared (missing from the plan's list) and
canUseFullScreenIntent() exposed so the Dart side can gate rather than warn.
Every permission verified present in android-36's api-versions.xml rather
than from recall. Compiles green with `flutter build apk --debug`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
All three were delegated to the Flutter Gradle plugin, so there was no floor
to reason about — and full-screen-intent and foreground-service semantics both
changed at API 34/35, which is exactly the code the alarm path needs.
- compileSdk/targetSdk = 36, matching the target phone.
- minSdk = 28: the floor for android:showWhenLocked / android:turnScreenOn as
plain manifest attributes (no legacy branch), which also clears
flutter_secure_storage's API 23 and Flutter's own default of 24.
Verified with `flutter build apk --debug` (exit 0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Groundwork for the alarm-hardening work. No behaviour change.
- .pre-commit-config.yaml pinned gatelock-v0.4.0 while pyproject.toml and
requirements.txt pinned v0.4.1, so pylint type-checked against a different
gatelock than production runs. All three now agree on v0.4.1.
- AGENTS.md claimed ALARM_DAYS is "Mon, Fri, Sat, Sun"; _constants.py:9 is
frozenset({0..6}), i.e. every day. Corrected.
- Complete the CLAUDE.md -> AGENTS.md symlink migration that was left
uncommitted in the working tree.
- DOCS-speaker-recommendation.md: item 7 of the hardening work. Prose only.
Researched from official manuals/vendor FAQs. The deciding spec turns out
to be the power switch, not the LED: _alarm.py already cuts plug mains on
every exit path, so a speaker needs a latching mechanical switch to come
back without a button press. Anything shipping a remote has a standby
microcontroller that a mains cycle cannot clear (confirmed by Edifier's own
support article), which disqualifies it.
- Drop the stale "TODO: Specify your own unique Application ID" template
comment; applicationId has been set to com.kuhy.wake_alarm_sync all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
The inline `_DeviceCodeDialog` (86 lines) was byte-for-byte identical to
diet-guard's, and `services/github_device_auth.dart` was a fourth copy of
the auth client. Both now come from `github_device_auth` in the
kuhyx/utils monorepo.
This app's client was not merely a drifted copy -- it carried a fix the
other three lacked: `pollForToken` swallowed a transient socket failure and
kept polling, because GitHub can close the connection at the exact moment
the user authorizes. Extracting todo's version would have silently deleted
that. The package took this behaviour instead, so the fix now protects all
four apps rather than just this one.
It is expressed as `on http.ClientException` rather than the local
`SocketException` arm, because the package is also imported by
diet-guard's **web** build where `dart:io` will not compile. Verified that
nothing is lost: package:http wraps a dead socket as
`_ClientSocketException`, which implements both types.
19 tests pass, analyze clean, and the release APK is installed on the
phone and launching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
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
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
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
Spotted on the device right after installing the migrated app: the home
screen still gated syncing on `settings.isConfigured`, which is only true
when a **GitHub token** is stored. A phone connected to Firebase and nothing
else would be told "Configure GitHub in Settings first" and refuse to push --
and once the GitHub mirror is retired, that would be every phone.
Both the gate and the hint now accept either backend, matching what
syncBackend() already does: Firebase alone, GitHub alone, or the mirror that
writes both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
Completes the cutover started on the PC side: phone_app was still pinned to
crdt_sync_dart-v0.2.0, so the Flutter half was writing only to GitHub even
though _sync.py had already moved.
pushWith() now takes a RemoteStore instead of a GitHubClient, so one call
serves Firebase, GitHub, or the MirrorStore that writes both; syncBackend()
picks which, and returns the bare GitHub client when this device has not been
set up for Firebase -- the normal cutover state, and also the rollback. The
remote path is byte-identical on both backends, so only the transport differs
and the PC's parse is untouched.
Settings gained the Firebase section that actually calls saveAccount(). Its
absence was the bug found in diet-guard: without it openFirebase() always
returned null, so the app was a Firebase no-op on real hardware no matter
what the tests said. Signing in happens immediately on Connect, so a wrong
password surfaces on-screen rather than as a silent failure at push time.
The three UI problems kuhy hit in diet-guard are avoided here rather than
repeated: GitHub is demoted into "Advanced (GitHub mirror)" instead of
sitting beside Firebase as a peer choice, the connected account renders as
read-only text with a Disconnect button rather than an empty-looking form,
and the status card reports the Firebase state it is actually reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
wake-alarm had no APK build at all; releases were not produced. Adds a
standalone release-apk.yml plus the release signing config the app was
missing.
Signing follows the pattern proven in home_inventory: gradle reads
android/key.properties when present and falls back to the debug key when
absent, so local `flutter run --release` is unaffected. CI writes that
file from repository secrets and shreds it afterwards, and apksigner
fails the build closed if a debug-signed APK ever appears.
This repo has no Flutter analyze/test job, so the release job is not
gated on tests — a broken commit still publishes, with the build failing
as the only backstop. Add `needs:` if a Flutter CI job is added later.
versionCode is github.run_number, which is per-workflow-file — this file
must never be renamed, or the counter resets and updates stop installing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
The escape-hatch form was a keyboard dead end. Tk traps Tab inside a tk.Text
(inserts a literal tab, refocuses the widget) and binds Shift-Tab to nothing,
so the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike
diet_guard's equivalent box, which at least binds <Return>, this one bound
nothing at all -- so a keyboard-only user who tabbed into the justification
field at 6am could not get back out to Submit.
Enter now activates Submit / Back / the escape button too. X11 Tk binds only
<space> on Button, so the key a user actually reaches for did nothing.
Escape returns to the alarm from the escape FORM. To be explicit, because this
app's whole point is resisting dismissal: this does not weaken the gating. The
alarm still requires DISMISS_ROUNDS_REQUIRED correct answers or the budgeted
escape hatch. The absent global <Escape>, the overrideredirect + grab_set_global
+ VT lock, and the hidden-until-earned escape button are deliberate adversarial
design and are left alone. What changed is that the *sanctioned* paths are now
reachable without a pointer.
Focus rings everywhere: the dismiss-code entry, the escape button, the
justification Text and the three escape-form fields all kept Tk's default 1px
BLACK ring, invisible against #211D1B. On a screen with no other window and a
global grab, that ring is the only feedback that a keystroke landed anywhere.
_alarm_view.py imported gatelock only under TYPE_CHECKING, so bind_activate
would have raised NameError at runtime while the module still imported cleanly
-- an import smoke test would have missed it.
phone_app is Android-only, so 1366x768 does not apply; its risk is landscape.
home_screen and settings_screen were bare Columns that cannot scroll, so
content overflowed with the stripe pattern painted over the primary control:
phone landscape leaves ~360px of body height for ~405px of content. Both now
scroll inside a SafeArea. The status line also rendered an unbounded raw
'Error: $e' with no maxLines, which added 100-150px and overflowed even in
portrait; it is capped at four lines.
The gatelock pin moves to v0.3.0, which is where the shared Tk-default
workarounds live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pubspec.yaml's adaptive_icon_background is a separate literal from
python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated
every generated PNG but missed this one, so the actual on-device home
screen icon (Android's adaptive icon, not the legacy mipmap) stayed on
the old cool charcoal. Caught on the physical device, not by file checks.
testsAndMisc's generator now syncs this automatically going forward.
Picks up gatelock's warm-tinted unified-design-system palette + the new
on_fill token. wake_alarm's own UI wasn't audited for the on-fill
contrast bug this session (out of the 3-pilot scope that found it in
diet-guard/screen-locker/dufs-cloud) -- worth a follow-up pass.
Verified: wake_alarm pytest 300/300 @ 100% coverage against the new pin.
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying an orange clock glyph, orange being this
app's own theme seedColor.
The clock is deliberately drawn without bell "ears" - at launcher size they
read as noise rather than as an alarm clock.
Adds the adaptive-icon layers the project never had, including an Android 13
monochrome layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
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