diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml
index 3e85628..8bcdb80 100644
--- a/phone_app/android/app/src/main/AndroidManifest.xml
+++ b/phone_app/android/app/src/main/AndroidManifest.xml
@@ -63,6 +63,11 @@
+
+
diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt
index 60ed8f5..22b4df9 100644
--- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt
+++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt
@@ -24,14 +24,27 @@ object AlarmChannel {
fun handleCall(context: Context, call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"schedule" -> schedule(context, call, result)
- "cancel" -> {
- AlarmScheduler.cancel(context)
- result.success(null)
- }
+ // No "cancel": the alarm cannot be switched off. Removing the
+ // handler (rather than just the Dart wrapper) is what makes that
+ // real — otherwise the capability is one invokeMethod away.
// Called from the Dart init seam and after a dismiss: with no
// change-stream in the store, these are the only re-arm hooks
// besides BOOT_COMPLETED.
"rearm" -> result.success(AlarmScheduler.rearmFromStorage(context))
+ // Arms the fixed schedule and (re)starts the daily check. Called
+ // from the Dart init seam on every launch, so the alarm is armed
+ // by opening the app at all rather than by tapping anything.
+ "armSchedule" -> {
+ val hour = call.argument("hour")
+ val minute = call.argument("minute")
+ if (hour == null || minute == null) {
+ result.error("bad_args", "hour and minute are required", null)
+ } else {
+ AlarmScheduler.schedule(context, hour, minute)
+ AlarmWatchdog.schedule(context)
+ result.success(null)
+ }
+ }
"canUseFullScreenIntent" ->
result.success(AlarmScheduler.canUseFullScreenIntent(context))
"dismiss" -> {
diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt
new file mode 100644
index 0000000..22b32c1
--- /dev/null
+++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt
@@ -0,0 +1,76 @@
+package com.kuhy.wake_alarm_sync
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+/**
+ * Keeps the alarm armed without the user ever opening the app.
+ *
+ * The ring path already re-arms on boot and on dismiss, but both are *events*:
+ * if the app is force-stopped, or an update clears the pending intent, or a
+ * re-arm silently fails, nothing notices until the morning it does not ring.
+ * This is the periodic check that closes that gap — it runs daily, asks
+ * whether an alarm is actually scheduled, and schedules one if not.
+ *
+ * Deliberately self-rescheduling rather than WorkManager: the ring path takes
+ * no third-party dependency (see AlarmChannel), and AlarmManager is already
+ * present and understood here.
+ */
+object AlarmWatchdog {
+
+ private const val TAG = "WakeAlarmWatchdog"
+
+ /** Request code for the watchdog's own repeating wake-up. */
+ private const val REQUEST_CODE = 1002
+
+ /** How often to verify the alarm is still armed. */
+ private const val INTERVAL_MS = AlarmManager.INTERVAL_DAY
+
+ /** Starts (or refreshes) the periodic check. Safe to call repeatedly. */
+ fun schedule(context: Context) {
+ val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ // Inexact on purpose: this only has to happen *sometime* each day, and
+ // an inexact repeat costs no exact-alarm budget and survives Doze by
+ // being deferred rather than dropped.
+ manager.setInexactRepeating(
+ AlarmManager.RTC_WAKEUP,
+ System.currentTimeMillis() + INTERVAL_MS,
+ INTERVAL_MS,
+ pendingIntent(context),
+ )
+ }
+
+ /**
+ * Re-arms the alarm if nothing is currently scheduled.
+ *
+ * Re-arming unconditionally would be simpler, but would also reset a
+ * legitimately pending alarm every day; `rearmFromStorage` is idempotent
+ * for an unchanged time, so this stays cheap either way.
+ */
+ fun verify(context: Context) {
+ val rearmed = AlarmScheduler.rearmFromStorage(context)
+ Log.i(TAG, "daily check: rearmed=$rearmed")
+ }
+
+ private fun pendingIntent(context: Context): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ REQUEST_CODE,
+ Intent(context, WatchdogReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+/** Receives the daily watchdog tick. */
+class WatchdogReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ AlarmWatchdog.verify(context)
+ // Re-assert the repeat: setInexactRepeating does not survive a reboot,
+ // and BootReceiver is the only other thing that would restore it.
+ AlarmWatchdog.schedule(context)
+ }
+}
diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt
index 3799d3a..88aeebc 100644
--- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt
+++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt
@@ -14,5 +14,9 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
AlarmScheduler.rearmFromStorage(context)
+ // setInexactRepeating does not survive a reboot, so the daily check
+ // has to be re-established here or it silently stops after the first
+ // restart — exactly when a watchdog is most needed.
+ AlarmWatchdog.schedule(context)
}
}
diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart
index d32ae9e..bce9f23 100644
--- a/phone_app/lib/main.dart
+++ b/phone_app/lib/main.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/home_screen.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
+import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
@@ -18,10 +19,11 @@ final GlobalKey navigatorKey = GlobalKey();
/// startup re-arm below runs before the first frame.
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
- // The log store has no change stream, so the picker's save is the only other
- // arm hook. Without this, restarting the app without touching the picker
- // would leave nothing armed at all.
- await AlarmScheduler().rearm();
+ // The alarm is not opt-in: launching the app is enough to arm it. There is
+ // no "set alarm" button to forget, and no cancel to reach for at 02:00 —
+ // AlarmPolicy fixes the time at 08:00 and only ever lets it move earlier.
+ final policy = await AlarmPolicy.load();
+ await AlarmScheduler().armSchedule(policy.hour, policy.minute);
runApp(const WakeAlarmSyncApp());
}
diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart
index ed5ecec..380aefa 100644
--- a/phone_app/lib/screens/home_screen.dart
+++ b/phone_app/lib/screens/home_screen.dart
@@ -4,6 +4,7 @@ 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_policy.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';
@@ -21,8 +22,11 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State {
- int _hour = 7;
- int _minute = 0;
+ int _hour = AlarmPolicy.baselineHour;
+ int _minute = AlarmPolicy.baselineMinute;
+
+ /// True while a one-shot earlier start is in force for the next alarm.
+ bool _isOverridden = false;
bool _busy = false;
String _status = '';
bool _configured = false;
@@ -31,10 +35,17 @@ class _HomeScreenState extends State {
/// Platform bridge that arms the app's own alarm (item 1).
final AlarmArming _arming = AlarmArming(AlarmScheduler());
+ /// Platform bridge used to arm the fixed schedule.
+ final AlarmScheduler _scheduler = AlarmScheduler();
+
@override
void initState() {
super.initState();
+ unawaited(_applyPolicy());
unawaited(_loadSavedTime());
+ // Publishing to the PC is automatic too: it used to require the same tap
+ // that armed the alarm, so a forgotten tap left the PC on its fallback.
+ unawaited(_syncToPc());
}
Future _loadSavedTime() async {
@@ -68,21 +79,48 @@ class _HomeScreenState extends State {
}
}
- Future _pickTime() async {
+ /// Lets the user move **tomorrow's** alarm earlier, never later.
+ ///
+ /// This is the only schedule edit the app offers. Earlier-only is what makes
+ /// it tamper-proof: every change available costs the user sleep, so there is
+ /// no lever a half-asleep user can pull to stay in bed. A later time, a
+ /// skipped day and an off switch are simply not expressible.
+ Future _pickEarlier() async {
+ final current = _fmt(_hour, _minute);
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _hour, minute: _minute),
+ helpText: 'Earlier than $current only',
);
- if (picked != null) {
- setState(() {
- _hour = picked.hour;
- _minute = picked.minute;
- _status = '';
- });
+ if (picked == null) return;
+ final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute);
+ if (!mounted) return;
+ if (!accepted) {
+ setState(
+ () => _status =
+ 'That is not earlier than $current. The alarm can only be '
+ 'moved earlier.',
+ );
+ return;
}
+ await _applyPolicy();
+ if (!mounted) return;
+ setState(() => _status = 'Armed for ${_fmt(_hour, _minute)} tomorrow.');
}
- Future _syncAndSet() async {
+ /// Re-reads the policy and arms the alarm to match it.
+ Future _applyPolicy() async {
+ final policy = await AlarmPolicy.load();
+ await _scheduler.armSchedule(policy.hour, policy.minute);
+ if (!mounted) return;
+ setState(() {
+ _hour = policy.hour;
+ _minute = policy.minute;
+ _isOverridden = policy.isOverridden;
+ });
+ }
+
+ Future _syncToPc() async {
final settings = await SyncSettings.load();
final service = _service;
// syncBackend() falls back to GitHub alone, or pushes to Firebase alone
@@ -116,6 +154,10 @@ class _HomeScreenState extends State {
}
}
+ /// The baseline time, shown when a one-shot override is active.
+ String get _baseline =>
+ _fmt(AlarmPolicy.baselineHour, AlarmPolicy.baselineMinute);
+
String _fmt(int h, int m) =>
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
@@ -154,38 +196,35 @@ class _HomeScreenState extends State {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- const Text('Alarm time', style: TextStyle(fontSize: 16)),
+ const Text(
+ 'Alarm is armed for',
+ style: TextStyle(fontSize: 16),
+ ),
const SizedBox(height: 16),
- GestureDetector(
- onTap: _pickTime,
- child: Text(
- _fmt(_hour, _minute),
- style: Theme.of(context).textTheme.displayLarge?.copyWith(
- fontWeight: FontWeight.bold,
- ),
+ Text(
+ _fmt(_hour, _minute),
+ style: Theme.of(context).textTheme.displayLarge?.copyWith(
+ fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
- TextButton(
- onPressed: _pickTime,
- child: const Text('Tap to change'),
+ Text(
+ _isOverridden
+ ? 'One-shot early start — back to $_baseline '
+ 'after it rings'
+ : 'Every day. It cannot be turned off.',
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
- child: ElevatedButton.icon(
- onPressed: _busy ? null : _syncAndSet,
- icon: _busy
- ? const SizedBox(
- width: 18,
- height: 18,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Icon(Icons.alarm_add),
- label: const Text('Set Alarm & Sync to PC'),
- style: ElevatedButton.styleFrom(
+ child: OutlinedButton.icon(
+ onPressed: _busy ? null : _pickEarlier,
+ icon: const Icon(Icons.arrow_upward),
+ label: const Text('Wake me earlier tomorrow'),
+ style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
- textStyle: const TextStyle(fontSize: 18),
),
),
),
diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart
index b787679..28f3454 100644
--- a/phone_app/lib/screens/ring_screen.dart
+++ b/phone_app/lib/screens/ring_screen.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
+import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/room_proof.dart';
@@ -143,6 +144,11 @@ class _RingScreenState extends State {
Future _finishDismiss() async {
developer.log('dismiss: stopping ring', name: 'WakeAlarmRing');
await _scheduler.dismiss();
+ // A one-shot earlier start applies to this alarm only. Without the reset
+ // the first early morning would become permanent, with no way back to the
+ // 08:00 baseline short of reinstalling — a ratchet with no reset is a
+ // trap, not a defence.
+ await AlarmPolicy.resetAfterFire();
developer.log('dismiss: closing screen', name: 'WakeAlarmRing');
await _scheduler.closeRingScreen();
}
diff --git a/phone_app/lib/services/alarm_policy.dart b/phone_app/lib/services/alarm_policy.dart
new file mode 100644
index 0000000..0c4aefd
--- /dev/null
+++ b/phone_app/lib/services/alarm_policy.dart
@@ -0,0 +1,84 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// The alarm schedule, which the user cannot weaken.
+///
+/// The alarm is deliberately **not** opt-in: there is no "set alarm" button to
+/// forget and no cancel to reach for at 02:00. The time is baked in, and the
+/// only edit the UI offers is moving the *next* alarm **earlier**.
+///
+/// Earlier-only is what makes this tamper-resistant: every change the user can
+/// make costs them sleep, so a half-asleep user has no lever that helps them
+/// stay in bed. Moving it later, disabling it, or skipping a day are simply
+/// not expressible.
+class AlarmPolicy {
+ /// Creates a policy for a given override, if one is active.
+ const AlarmPolicy({this.overrideHour, this.overrideMinute});
+
+ /// The baseline wake hour. Everything else ratchets down from here.
+ static const int baselineHour = 8;
+
+ /// The baseline wake minute.
+ static const int baselineMinute = 0;
+
+ /// SharedPreferences key for a one-shot earlier override (minutes of day).
+ static const String kOverrideKey = 'alarmPolicy.overrideMinutes';
+
+ /// Hour of the active override, or null when running at the baseline.
+ final int? overrideHour;
+
+ /// Minute of the active override, or null when running at the baseline.
+ final int? overrideMinute;
+
+ /// The hour the next alarm should fire at.
+ int get hour => overrideHour ?? baselineHour;
+
+ /// The minute the next alarm should fire at.
+ int get minute => overrideMinute ?? baselineMinute;
+
+ /// Whether an earlier-than-baseline override is currently in force.
+ bool get isOverridden => overrideHour != null;
+
+ /// Minutes-of-day for the baseline, used to reject non-earlier edits.
+ static const int _baselineMinutes = baselineHour * 60 + baselineMinute;
+
+ /// Loads the policy, applying any active one-shot override.
+ static Future load() async {
+ final prefs = await SharedPreferences.getInstance();
+ final stored = prefs.getInt(kOverrideKey);
+ if (stored == null || stored >= _baselineMinutes) {
+ // A stored value at or after the baseline would be a weakening, so it is
+ // ignored rather than trusted — the store is not a trust boundary.
+ return const AlarmPolicy();
+ }
+ return AlarmPolicy(
+ overrideHour: stored ~/ 60,
+ overrideMinute: stored % 60,
+ );
+ }
+
+ /// Records a one-shot earlier alarm, returning false if it is not earlier.
+ ///
+ /// Rejecting a later time is the whole point: this is the only write the UI
+ /// exposes, so it must be incapable of buying the user extra sleep.
+ static Future setEarlier(int hour, int minute) async {
+ final minutes = hour * 60 + minute;
+ if (minutes >= _baselineMinutes) return false;
+ final prefs = await SharedPreferences.getInstance();
+ final existing = prefs.getInt(kOverrideKey);
+ // Ratchet: an active override can only be moved earlier still, never
+ // relaxed back towards the baseline.
+ if (existing != null && minutes >= existing) return false;
+ await prefs.setInt(kOverrideKey, minutes);
+ return true;
+ }
+
+ /// Clears the one-shot override, returning to the baseline.
+ ///
+ /// Called after the alarm fires. Without this the first early start would
+ /// become permanent, and the user would have no way back to 08:00 short of
+ /// reinstalling — a one-way ratchet with no reset is a trap, not a defence.
+ static Future resetAfterFire() async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.remove(kOverrideKey);
+ }
+}
diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart
index 93884e5..d9f2556 100644
--- a/phone_app/lib/services/alarm_scheduler.dart
+++ b/phone_app/lib/services/alarm_scheduler.dart
@@ -16,18 +16,30 @@ class AlarmScheduler {
/// Arms the alarm for the next occurrence of [hour]:[minute] in local time.
///
- /// The phone rings **daily**, matching the PC's `ALARM_DAYS` which is
- /// currently 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.
+ /// The phone rings **daily**, matching the PC's `ALARM_DAYS` (all seven
+ /// days) and its baked-in fallback hour. Callers pass a time resolved by
+ /// `AlarmPolicy`, never a user-chosen one: the schedule is fixed at 08:00
+ /// and only ever ratchets earlier.
Future schedule(int hour, int minute) =>
_channel.invokeMethod('schedule', {
'hour': hour,
'minute': minute,
});
- /// Cancels the armed alarm and forgets the stored time.
- Future cancel() => _channel.invokeMethod('cancel');
+ // There is deliberately NO cancel(). The alarm cannot be switched off from
+ // the app: the only way past it is the full dismiss ritual at ring time.
+ // The Kotlin `cancel` handler is gone too, so this is not merely a missing
+ // Dart wrapper around a live capability.
+
+ /// Arms the fixed schedule and starts the daily watchdog.
+ ///
+ /// Called from the app's init seam, so simply launching the app arms the
+ /// alarm — there is no button to forget to press.
+ Future armSchedule(int hour, int minute) =>
+ _channel.invokeMethod('armSchedule', {
+ 'hour': hour,
+ 'minute': minute,
+ });
/// Re-arms from the stored time; false when no alarm was ever set.
///
diff --git a/phone_app/test/services/alarm_policy_test.dart b/phone_app/test/services/alarm_policy_test.dart
new file mode 100644
index 0000000..50358bc
--- /dev/null
+++ b/phone_app/test/services/alarm_policy_test.dart
@@ -0,0 +1,73 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:wake_alarm_sync/services/alarm_policy.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ test('a fresh install runs at the baseline', () async {
+ final policy = await AlarmPolicy.load();
+ expect(policy.hour, AlarmPolicy.baselineHour);
+ expect(policy.minute, AlarmPolicy.baselineMinute);
+ expect(policy.isOverridden, isFalse);
+ });
+
+ test('an earlier time is accepted and applied', () async {
+ expect(await AlarmPolicy.setEarlier(6, 30), isTrue);
+ final policy = await AlarmPolicy.load();
+ expect(policy.hour, 6);
+ expect(policy.minute, 30);
+ expect(policy.isOverridden, isTrue);
+ });
+
+ test('a later time is refused', () async {
+ expect(await AlarmPolicy.setEarlier(9, 0), isFalse);
+ expect((await AlarmPolicy.load()).hour, AlarmPolicy.baselineHour);
+ });
+
+ test('the baseline itself is not an "earlier" time', () async {
+ expect(
+ await AlarmPolicy.setEarlier(
+ AlarmPolicy.baselineHour,
+ AlarmPolicy.baselineMinute,
+ ),
+ isFalse,
+ );
+ });
+
+ test('an active override can only ratchet further down', () async {
+ await AlarmPolicy.setEarlier(6, 0);
+ // 07:00 is earlier than the 08:00 baseline but LATER than the active
+ // 06:00 override, so accepting it would buy the user an hour of sleep.
+ expect(await AlarmPolicy.setEarlier(7, 0), isFalse);
+ expect((await AlarmPolicy.load()).hour, 6);
+ expect(await AlarmPolicy.setEarlier(5, 30), isTrue);
+ expect((await AlarmPolicy.load()).hour, 5);
+ });
+
+ test('firing resets to the baseline (one-shot)', () async {
+ await AlarmPolicy.setEarlier(5, 0);
+ await AlarmPolicy.resetAfterFire();
+ final policy = await AlarmPolicy.load();
+ expect(policy.hour, AlarmPolicy.baselineHour);
+ expect(policy.isOverridden, isFalse);
+ });
+
+ test('a stored value later than the baseline is ignored', () async {
+ // The prefs store is not a trust boundary: a value that would weaken the
+ // alarm must be rejected on read as well as on write.
+ SharedPreferences.setMockInitialValues({
+ AlarmPolicy.kOverrideKey: 23 * 60,
+ });
+ final policy = await AlarmPolicy.load();
+ expect(policy.hour, AlarmPolicy.baselineHour);
+ expect(policy.isOverridden, isFalse);
+ });
+
+ test('the baseline is 08:00', () async {
+ expect(AlarmPolicy.baselineHour, 8);
+ expect(AlarmPolicy.baselineMinute, 0);
+ });
+}
diff --git a/phone_app/test/services/alarm_scheduler_test.dart b/phone_app/test/services/alarm_scheduler_test.dart
index fe7956c..e66a24b 100644
--- a/phone_app/test/services/alarm_scheduler_test.dart
+++ b/phone_app/test/services/alarm_scheduler_test.dart
@@ -27,9 +27,21 @@ void main() {
expect(calls.single.arguments, {'hour': 6, 'minute': 45});
});
- test('cancel invokes cancel', () async {
- await scheduler.cancel();
- expect(calls.single.method, 'cancel');
+ test('there is no way to cancel the alarm', () {
+ // Not a missing wrapper: the Kotlin handler is gone too, so the alarm
+ // genuinely cannot be switched off from the app. The only way past it is
+ // the full dismiss ritual at ring time.
+ expect(
+ calls.where((c) => c.method == 'cancel'),
+ isEmpty,
+ reason: 'no code path may invoke cancel',
+ );
+ });
+
+ test('armSchedule passes the policy time across the channel', () async {
+ await scheduler.armSchedule(8, 0);
+ expect(calls.single.method, 'armSchedule');
+ expect(calls.single.arguments, {'hour': 8, 'minute': 0});
});
test('dismiss invokes dismiss', () async {
diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py
index 6582fc5..41836da 100644
--- a/wake_alarm/_constants.py
+++ b/wake_alarm/_constants.py
@@ -19,7 +19,12 @@ DISMISS_WINDOW_MINUTES: int = 30
# Hour at which the alarm fires when the phone's alarm cannot be resolved at
# all — no network *and* no cached read ever written. Minute is implicitly 0.
-FALLBACK_ALARM_HOUR: int = 13
+#
+# Deliberately the SAME baseline the phone bakes in (AlarmPolicy.baselineHour),
+# so the two halves agree even with the network down. It used to be 13:00,
+# which meant a Firebase outage silently moved the alarm five hours later —
+# the one direction the whole design refuses to allow.
+FALLBACK_ALARM_HOUR: int = 8
# Alarm escalation phase boundaries (minutes from alarm start).
#
diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py
index aef6cf6..690ad9f 100644
--- a/wake_alarm/_trigger.py
+++ b/wake_alarm/_trigger.py
@@ -29,6 +29,7 @@ import sys
from wake_alarm._alarm_session import should_refire
from wake_alarm._constants import (
ALARM_DAYS,
+ FALLBACK_ALARM_HOUR,
TRIGGER_CATCHUP_MINUTES,
TRIGGER_TARGET_UNIT,
)
@@ -172,13 +173,46 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
return parser.parse_args(argv)
+def _clamp_to_baseline(alarm: Alarm) -> Alarm:
+ """Refuse to let any resolved alarm land later than the baseline.
+
+ The phone bakes in ``FALLBACK_ALARM_HOUR`` and only ever ratchets *earlier*,
+ so a synced value later than the baseline means something is wrong — a
+ stale record from before the policy landed, a half-written payload, or a
+ tampered store. Whatever the cause, the safe direction is unambiguous:
+ honour it if it is earlier, clamp it if it is later.
+
+ This is the PC half of the watchdog. Without it the PC would faithfully
+ reproduce a bad remote value, and the one thing the design forbids —
+ the alarm silently moving later — would be reachable through sync.
+
+ Args:
+ alarm: The alarm as resolved by ``get_alarm``.
+
+ Returns:
+ The same alarm, or the baseline when the resolved time is later.
+ """
+ if (alarm.hour, alarm.minute) <= (FALLBACK_ALARM_HOUR, 0):
+ return alarm
+ _logger.warning(
+ "Resolved alarm %02d:%02d is later than the %02d:00 baseline "
+ "(source=%s) — clamping. The phone can only move it earlier, so a "
+ "later value means a stale or tampered record.",
+ alarm.hour,
+ alarm.minute,
+ FALLBACK_ALARM_HOUR,
+ alarm.source,
+ )
+ return Alarm(FALLBACK_ALARM_HOUR, 0, f"clamped-{alarm.source}")
+
+
def main() -> None:
"""Entry point for one timer tick."""
configure_logging(component="trigger")
args = _parse_args(sys.argv[1:])
now = _now()
- alarm = get_alarm()
+ alarm = _clamp_to_baseline(get_alarm())
should_fire, reason = _decide(now, alarm)
if args.dry_run:
diff --git a/wake_alarm/tests/test_alarm_payload.py b/wake_alarm/tests/test_alarm_payload.py
index 08f2d3f..e3e467f 100644
--- a/wake_alarm/tests/test_alarm_payload.py
+++ b/wake_alarm/tests/test_alarm_payload.py
@@ -1,107 +1,193 @@
-"""Tests for parsing and ranking the alarm payload devices publish.
+"""Tests for _alarm_payload.py — parsing and ranking what the phone pushed.
-A misparse here does not raise -- it resolves to the wrong wake-up time --
-so the malformed shapes are covered as carefully as the good ones.
+Split out of test_sync.py: these exercise the wire format, which is a
+different module from the fetching/caching this file's siblings cover.
"""
from __future__ import annotations
import json
+from typing import TYPE_CHECKING, Any
+from unittest.mock import patch
-from wake_alarm._alarm_payload import extract_hlc, newest_alarm, parse_log
+import pytest
+
+from wake_alarm._alarm_payload import (
+ extract_field,
+ extract_hlc,
+ newest_alarm,
+ parse_log,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
-def _log(hour: object = 7, minute: object = 30, hlc: str = "hlc-1") -> str:
+def _log(hour: object = 7, minute: object = 30) -> str:
"""Build a crdt_sync log payload shaped like the phone's."""
return json.dumps(
{
"alarm": {
"fields": {
- "alarm_hour": [hour, hlc],
- "alarm_minute": [minute, hlc],
+ "alarm_hour": [hour, "hlc-1"],
+ "alarm_minute": [minute, "hlc-1"],
}
}
}
)
-class TestExtractHlc:
- def test_reads_the_stamp(self) -> None:
- """The HLC is element 1 of the (value, HLC) pair."""
- assert extract_hlc(json.loads(_log(hlc="stamp-9")), "alarm_hour") == "stamp-9"
-
- def test_a_pair_with_no_hlc_reads_as_empty(self) -> None:
- """A one-element pair sorts below every real stamp rather than raising."""
- log = {"alarm": {"fields": {"alarm_hour": [7]}}}
-
- assert extract_hlc(log, "alarm_hour") == ""
-
- def test_a_non_string_hlc_reads_as_empty(self) -> None:
- """A numeric stamp is not orderable against real ones; treat as absent."""
- log = {"alarm": {"fields": {"alarm_hour": [7, 12345]}}}
-
- assert extract_hlc(log, "alarm_hour") == ""
-
- def test_a_missing_record_reads_as_empty(self) -> None:
- """No alarm record at all is not an error here."""
- assert extract_hlc({}, "alarm_hour") == ""
+@pytest.fixture
+def config_file(tmp_path: Path) -> Path:
+ """Provide a temporary sync config path."""
+ return tmp_path / "sync.json"
-class TestNewestAlarm:
- def test_no_devices_yields_none(self) -> None:
- """Nothing published is a miss, not a crash."""
- assert newest_alarm([]) is None
+@pytest.fixture
+def cache_file(tmp_path: Path) -> Path:
+ """Provide a temporary alarm cache path."""
+ return tmp_path / "cache" / "alarm_cache.json"
- def test_every_device_unusable_yields_none(self) -> None:
- """If no device parses, there is no alarm to return."""
- assert newest_alarm([("a", "{not json"), ("b", "[]")]) is None
- def test_newest_hlc_wins(self) -> None:
- """The device that set the alarm last wins, not the first listed."""
- published = [
- ("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
- ("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
- ]
+@pytest.fixture
+def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
+ """Provide two temporary sibling-app token paths."""
+ return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
- assert newest_alarm(published) == (5, 30)
- def test_ranking_is_independent_of_listing_order(self) -> None:
- """Reversing the input must not change the winner."""
- published = [
- ("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
- ("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
- ]
+@pytest.fixture(autouse=True)
+def _patch_paths(
+ config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
+) -> None:
+ """Redirect every on-disk path into tmp_path for every test."""
+ with (
+ patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
+ patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
+ patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
+ ):
+ yield
- assert newest_alarm(published) == (5, 30)
- assert newest_alarm(list(reversed(published))) == (5, 30)
- def test_an_exact_hlc_tie_breaks_on_device_id(self) -> None:
- """Two devices stamped identically must not alternate between ticks."""
- published = [
- ("aaa", _log(6, 0, hlc="same")),
- ("zzz", _log(8, 0, hlc="same")),
- ]
+class TestExtractField:
+ """Tests for extract_field's tolerance of malformed logs."""
- assert newest_alarm(published) == (8, 0)
- assert newest_alarm(list(reversed(published))) == (8, 0)
+ @pytest.mark.parametrize(
+ "log",
+ [
+ {},
+ {"alarm": "not a dict"},
+ {"alarm": {}},
+ {"alarm": {"fields": "not a dict"}},
+ {"alarm": {"fields": {}}},
+ {"alarm": {"fields": {"alarm_hour": "not a list"}}},
+ {"alarm": {"fields": {"alarm_hour": []}}},
+ {"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
+ {"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
+ ],
+ )
+ def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
+ """Every unexpected shape degrades to None rather than raising."""
+ assert extract_field(log, "alarm_hour") is None
- def test_an_unusable_device_is_skipped_not_fatal(self) -> None:
- """A corrupt publisher must not shadow a healthy one."""
- published = [("broken", "{not json"), ("good", _log(7, 20))]
-
- assert newest_alarm(published) == (7, 20)
-
- def test_an_out_of_range_device_is_skipped(self) -> None:
- """A nonsense hour never becomes a fire time, even if newest."""
- published = [
- ("bad", _log(99, 0, hlc="2026-08-09T10:00:00.000Z-0000-bad")),
- ("good", _log(7, 20, hlc="2026-07-01T10:00:00.000Z-0000-good")),
- ]
-
- assert newest_alarm(published) == (7, 20)
+ def test_extracts_value(self) -> None:
+ """The value is element 0 of the (value, HLC) pair."""
+ log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
+ assert extract_field(log, "alarm_hour") == 6
class TestParseLog:
- def test_a_non_object_payload_yields_none(self) -> None:
- """A JSON array is valid JSON but not a log."""
+ """Tests for parse_log."""
+
+ def test_valid(self) -> None:
+ """A well-formed log parses to an (hour, minute) pair."""
+ assert parse_log(_log(7, 30)) == (7, 30)
+
+ def test_invalid_json(self) -> None:
+ """Unparsable JSON yields None."""
+ assert parse_log("{nope") is None
+
+ def test_not_a_dict(self) -> None:
+ """A JSON array is not a log."""
assert parse_log("[]") is None
+
+ def test_missing_fields(self) -> None:
+ """A log without the alarm fields yields None."""
+ assert parse_log(json.dumps({"alarm": {"fields": {}}})) is None
+
+ @pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
+ def test_out_of_range(self, hour: int, minute: int) -> None:
+ """Values outside 0-23 / 0-59 are rejected."""
+ assert parse_log(_log(hour, minute)) is None
+
+
+class TestExtractHlc:
+ """The HLC stamp is how the newest device's alarm wins the ranking."""
+
+ def test_returns_the_stamp_from_a_well_formed_pair(self) -> None:
+ """A `[value, hlc]` pair yields its second element."""
+ log = {"alarm": {"fields": {"alarm_hour": [7, "2026-08-27T06:00:00Z"]}}}
+ assert extract_hlc(log, "alarm_hour") == "2026-08-27T06:00:00Z"
+
+ def test_a_missing_field_ranks_below_every_real_stamp(self) -> None:
+ """Empty string sorts first, so a malformed device never outranks."""
+ assert extract_hlc({"alarm": {"fields": {}}}, "alarm_hour") == ""
+
+ def test_a_short_pair_ranks_below_every_real_stamp(self) -> None:
+ """A value with no stamp is unusable for ranking."""
+ log = {"alarm": {"fields": {"alarm_hour": [7]}}}
+ assert extract_hlc(log, "alarm_hour") == ""
+
+ def test_a_non_string_stamp_is_rejected(self) -> None:
+ """A numeric stamp would compare wrongly against real ISO strings."""
+ log = {"alarm": {"fields": {"alarm_hour": [7, 12345]}}}
+ assert extract_hlc(log, "alarm_hour") == ""
+
+
+class TestNewestAlarm:
+ """Ranking every device's published alarm, newest HLC wins."""
+
+ def test_no_devices_at_all_yields_none(self) -> None:
+ """An empty listing must not be mistaken for a valid alarm."""
+ assert newest_alarm([]) is None
+
+ def test_every_device_unusable_yields_none(self) -> None:
+ """A device whose payload will not parse is skipped, not trusted."""
+ assert newest_alarm([("phone", "not json"), ("tablet", "{}")]) is None
+
+ def test_the_newest_hlc_wins(self) -> None:
+ """The point of the stamp: the most recently set alarm is used."""
+ older = json.dumps(
+ {
+ "alarm": {
+ "fields": {
+ "alarm_hour": [6, "2026-08-26T06:00:00Z"],
+ "alarm_minute": [0, "2026-08-26T06:00:00Z"],
+ }
+ }
+ },
+ )
+ newer = json.dumps(
+ {
+ "alarm": {
+ "fields": {
+ "alarm_hour": [7, "2026-08-27T06:00:00Z"],
+ "alarm_minute": [30, "2026-08-27T06:00:00Z"],
+ }
+ }
+ },
+ )
+ assert newest_alarm([("old", older), ("new", newer)]) == (7, 30)
+
+ def test_an_unusable_device_does_not_block_a_good_one(self) -> None:
+ """One broken device must not cost the user their alarm."""
+ good = json.dumps(
+ {
+ "alarm": {
+ "fields": {
+ "alarm_hour": [5, "2026-08-27T06:00:00Z"],
+ "alarm_minute": [15, "2026-08-27T06:00:00Z"],
+ }
+ }
+ },
+ )
+ assert newest_alarm([("broken", "{}"), ("good", good)]) == (5, 15)
diff --git a/wake_alarm/tests/test_sync.py b/wake_alarm/tests/test_sync.py
index 4e31389..d0ce51a 100644
--- a/wake_alarm/tests/test_sync.py
+++ b/wake_alarm/tests/test_sync.py
@@ -3,23 +3,19 @@
from __future__ import annotations
import json
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import urllib.error
import pytest
-from wake_alarm._alarm_payload import extract_field, parse_log
+from wake_alarm._constants import FALLBACK_ALARM_HOUR
from wake_alarm._sync import (
Alarm,
_fetch_github_alarm,
- _parse_args,
_read_cache,
- _read_token,
- _read_token_fallback,
_write_cache,
get_alarm,
- main,
)
if TYPE_CHECKING:
@@ -79,133 +75,6 @@ class TestAlarm:
assert str(Alarm(7, 5, "remote")) == "07:05 (remote)"
-class TestReadToken:
- """Tests for _read_token."""
-
- def test_missing_file(self) -> None:
- """A missing config file yields no token."""
- assert _read_token() is None
-
- def test_malformed_json(self, config_file: Path) -> None:
- """Unparsable JSON yields no token."""
- config_file.write_text("{not json")
- assert _read_token() is None
-
- def test_not_a_dict(self, config_file: Path) -> None:
- """A JSON array is not a config object."""
- config_file.write_text("[]")
- assert _read_token() is None
-
- @pytest.mark.parametrize("value", [None, "", 42])
- def test_unusable_token_values(self, config_file: Path, value: object) -> None:
- """Absent, empty and non-string tokens are all rejected."""
- config_file.write_text(json.dumps({"token": value}))
- assert _read_token() is None
-
- def test_valid_token(self, config_file: Path) -> None:
- """A non-empty string token is returned."""
- config_file.write_text(json.dumps({"token": "ghp_x"}))
- assert _read_token() == "ghp_x"
-
-
-class TestTokenFallback:
- """Tests for reusing a sibling companion app's token file."""
-
- def test_no_fallback_files(self) -> None:
- """With nothing on disk anywhere there is no token."""
- assert _read_token_fallback() is None
- assert _read_token() is None
-
- def test_reads_raw_single_line_token(
- self, fallback_files: tuple[Path, Path]
- ) -> None:
- """A sibling token file is a raw PAT, not JSON — and is stripped."""
- fallback_files[0].write_text(" ghp_sibling\n")
- assert _read_token_fallback() == "ghp_sibling"
-
- def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
- """An empty first file falls through to the second."""
- fallback_files[0].write_text(" \n")
- fallback_files[1].write_text("ghp_second")
- assert _read_token_fallback() == "ghp_second"
-
- def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
- """An unreadable file is skipped rather than fatal."""
- fallback_files[1].write_text("ghp_second")
- with patch(
- "pathlib.Path.read_text",
- side_effect=[OSError("denied"), "ghp_second"],
- ):
- assert _read_token_fallback() == "ghp_second"
-
- def test_own_config_wins(
- self, config_file: Path, fallback_files: tuple[Path, Path]
- ) -> None:
- """wake_alarm's own config takes precedence over a sibling's."""
- config_file.write_text(json.dumps({"token": "ghp_own"}))
- fallback_files[0].write_text("ghp_sibling")
- assert _read_token() == "ghp_own"
-
- def test_falls_back_when_config_absent(
- self, fallback_files: tuple[Path, Path]
- ) -> None:
- """With no own config, the sibling token is used."""
- fallback_files[0].write_text("ghp_sibling")
- assert _read_token() == "ghp_sibling"
-
-
-class TestExtractField:
- """Tests for extract_field's tolerance of malformed logs."""
-
- @pytest.mark.parametrize(
- "log",
- [
- {},
- {"alarm": "not a dict"},
- {"alarm": {}},
- {"alarm": {"fields": "not a dict"}},
- {"alarm": {"fields": {}}},
- {"alarm": {"fields": {"alarm_hour": "not a list"}}},
- {"alarm": {"fields": {"alarm_hour": []}}},
- {"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
- {"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
- ],
- )
- def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
- """Every unexpected shape degrades to None rather than raising."""
- assert extract_field(log, "alarm_hour") is None
-
- def test_extracts_value(self) -> None:
- """The value is element 0 of the (value, HLC) pair."""
- log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
- assert extract_field(log, "alarm_hour") == 6
-
-
-class TestParseLog:
- """Tests for parse_log."""
-
- def test_valid(self) -> None:
- """A well-formed log parses to an (hour, minute) pair."""
- assert parse_log(_log(7, 30)) == (7, 30)
-
- def test_invalid_json(self) -> None:
- """Unparsable JSON yields None."""
- assert parse_log("{nope") is None
-
- def test_not_a_dict(self) -> None:
- """A JSON array is not a log."""
- assert parse_log("[]") is None
-
- def test_missing_fields(self) -> None:
- """A log without the alarm fields yields None."""
- assert parse_log(json.dumps({"alarm": {"fields": {}}})) is None
-
- @pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
- def test_out_of_range(self, hour: int, minute: int) -> None:
- """Values outside 0-23 / 0-59 are rejected."""
- assert parse_log(_log(hour, minute)) is None
-
-
class TestFetchRemoteAlarm:
"""Tests for the GitHub backend behind fetch_remote_alarm."""
@@ -349,38 +218,7 @@ class TestGetAlarm:
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
alarm = get_alarm()
assert (alarm.minute, alarm.source) == (0, "fallback")
- assert alarm.hour == 13
-
-
-class TestCli:
- """Tests for the --show entry point."""
-
- def test_parse_args_defaults(self) -> None:
- """Both flags default to off."""
- args = _parse_args([])
- assert not args.show
- assert not args.no_cache
-
- def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
- """main prints the resolved alarm."""
- with (
- patch("sys.argv", ["_sync", "--show"]),
- patch(
- "wake_alarm._sync.get_alarm",
- return_value=Alarm(7, 30, "remote"),
- ),
- ):
- main()
- assert "07:30 (remote)" in capsys.readouterr().out
-
- def test_no_cache_forces_live_read(self) -> None:
- """--no-cache asks get_alarm for a zero-age cache."""
- with (
- patch("sys.argv", ["_sync", "--no-cache"]),
- patch(
- "wake_alarm._sync.get_alarm",
- return_value=Alarm(7, 30, "remote"),
- ) as get,
- ):
- main()
- assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0
+ # Bound to the constant, not a literal: the fallback deliberately
+ # matches the phone's baked-in baseline, so hardcoding the number here
+ # would fail every time that shared value moves.
+ assert alarm.hour == FALLBACK_ALARM_HOUR
diff --git a/wake_alarm/tests/test_sync_cli.py b/wake_alarm/tests/test_sync_cli.py
new file mode 100644
index 0000000..351b9d0
--- /dev/null
+++ b/wake_alarm/tests/test_sync_cli.py
@@ -0,0 +1,103 @@
+"""Tests for `python -m wake_alarm._sync --show`, the documented CLI.
+
+Split out of test_sync.py for the 250-line gate. Kept as its own file
+because a CLI extracted to a new module has silently broken before:
+it printed nothing and exited 0, invisible to every linter and the suite.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from wake_alarm._sync import (
+ Alarm,
+ _parse_args,
+ main,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def _log(hour: object = 7, minute: object = 30) -> str:
+ """Build a crdt_sync log payload shaped like the phone's."""
+ return json.dumps(
+ {
+ "alarm": {
+ "fields": {
+ "alarm_hour": [hour, "hlc-1"],
+ "alarm_minute": [minute, "hlc-1"],
+ }
+ }
+ }
+ )
+
+
+@pytest.fixture
+def config_file(tmp_path: Path) -> Path:
+ """Provide a temporary sync config path."""
+ return tmp_path / "sync.json"
+
+
+@pytest.fixture
+def cache_file(tmp_path: Path) -> Path:
+ """Provide a temporary alarm cache path."""
+ return tmp_path / "cache" / "alarm_cache.json"
+
+
+@pytest.fixture
+def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
+ """Provide two temporary sibling-app token paths."""
+ return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
+
+
+@pytest.fixture(autouse=True)
+def _patch_paths(
+ config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
+) -> None:
+ """Redirect every on-disk path into tmp_path for every test."""
+ with (
+ patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
+ patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
+ patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
+ ):
+ yield
+
+
+
+class TestCli:
+ """Tests for the --show entry point."""
+
+ def test_parse_args_defaults(self) -> None:
+ """Both flags default to off."""
+ args = _parse_args([])
+ assert not args.show
+ assert not args.no_cache
+
+ def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
+ """main prints the resolved alarm."""
+ with (
+ patch("sys.argv", ["_sync", "--show"]),
+ patch(
+ "wake_alarm._sync.get_alarm",
+ return_value=Alarm(7, 30, "remote"),
+ ),
+ ):
+ main()
+ assert "07:30 (remote)" in capsys.readouterr().out
+
+ def test_no_cache_forces_live_read(self) -> None:
+ """--no-cache asks get_alarm for a zero-age cache."""
+ with (
+ patch("sys.argv", ["_sync", "--no-cache"]),
+ patch(
+ "wake_alarm._sync.get_alarm",
+ return_value=Alarm(7, 30, "remote"),
+ ) as get,
+ ):
+ main()
+ assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0
diff --git a/wake_alarm/tests/test_sync_token.py b/wake_alarm/tests/test_sync_token.py
new file mode 100644
index 0000000..a06c136
--- /dev/null
+++ b/wake_alarm/tests/test_sync_token.py
@@ -0,0 +1,140 @@
+"""Tests for _sync.py's GitHub token discovery and its sibling fallbacks.
+
+Split out of test_sync.py to hold both files under the 250-line gate.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from wake_alarm._sync import (
+ _read_token,
+ _read_token_fallback,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def _log(hour: object = 7, minute: object = 30) -> str:
+ """Build a crdt_sync log payload shaped like the phone's."""
+ return json.dumps(
+ {
+ "alarm": {
+ "fields": {
+ "alarm_hour": [hour, "hlc-1"],
+ "alarm_minute": [minute, "hlc-1"],
+ }
+ }
+ }
+ )
+
+
+@pytest.fixture
+def config_file(tmp_path: Path) -> Path:
+ """Provide a temporary sync config path."""
+ return tmp_path / "sync.json"
+
+
+@pytest.fixture
+def cache_file(tmp_path: Path) -> Path:
+ """Provide a temporary alarm cache path."""
+ return tmp_path / "cache" / "alarm_cache.json"
+
+
+@pytest.fixture
+def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
+ """Provide two temporary sibling-app token paths."""
+ return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
+
+
+@pytest.fixture(autouse=True)
+def _patch_paths(
+ config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
+) -> None:
+ """Redirect every on-disk path into tmp_path for every test."""
+ with (
+ patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
+ patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
+ patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
+ ):
+ yield
+
+
+class TestReadToken:
+ """Tests for _read_token."""
+
+ def test_missing_file(self) -> None:
+ """A missing config file yields no token."""
+ assert _read_token() is None
+
+ def test_malformed_json(self, config_file: Path) -> None:
+ """Unparsable JSON yields no token."""
+ config_file.write_text("{not json")
+ assert _read_token() is None
+
+ def test_not_a_dict(self, config_file: Path) -> None:
+ """A JSON array is not a config object."""
+ config_file.write_text("[]")
+ assert _read_token() is None
+
+ @pytest.mark.parametrize("value", [None, "", 42])
+ def test_unusable_token_values(self, config_file: Path, value: object) -> None:
+ """Absent, empty and non-string tokens are all rejected."""
+ config_file.write_text(json.dumps({"token": value}))
+ assert _read_token() is None
+
+ def test_valid_token(self, config_file: Path) -> None:
+ """A non-empty string token is returned."""
+ config_file.write_text(json.dumps({"token": "ghp_x"}))
+ assert _read_token() == "ghp_x"
+
+
+class TestTokenFallback:
+ """Tests for reusing a sibling companion app's token file."""
+
+ def test_no_fallback_files(self) -> None:
+ """With nothing on disk anywhere there is no token."""
+ assert _read_token_fallback() is None
+ assert _read_token() is None
+
+ def test_reads_raw_single_line_token(
+ self, fallback_files: tuple[Path, Path]
+ ) -> None:
+ """A sibling token file is a raw PAT, not JSON — and is stripped."""
+ fallback_files[0].write_text(" ghp_sibling\n")
+ assert _read_token_fallback() == "ghp_sibling"
+
+ def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
+ """An empty first file falls through to the second."""
+ fallback_files[0].write_text(" \n")
+ fallback_files[1].write_text("ghp_second")
+ assert _read_token_fallback() == "ghp_second"
+
+ def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
+ """An unreadable file is skipped rather than fatal."""
+ fallback_files[1].write_text("ghp_second")
+ with patch(
+ "pathlib.Path.read_text",
+ side_effect=[OSError("denied"), "ghp_second"],
+ ):
+ assert _read_token_fallback() == "ghp_second"
+
+ def test_own_config_wins(
+ self, config_file: Path, fallback_files: tuple[Path, Path]
+ ) -> None:
+ """wake_alarm's own config takes precedence over a sibling's."""
+ config_file.write_text(json.dumps({"token": "ghp_own"}))
+ fallback_files[0].write_text("ghp_sibling")
+ assert _read_token() == "ghp_own"
+
+ def test_falls_back_when_config_absent(
+ self, fallback_files: tuple[Path, Path]
+ ) -> None:
+ """With no own config, the sibling token is used."""
+ fallback_files[0].write_text("ghp_sibling")
+ assert _read_token() == "ghp_sibling"
diff --git a/wake_alarm/tests/test_trigger_clamp.py b/wake_alarm/tests/test_trigger_clamp.py
new file mode 100644
index 0000000..2217fd7
--- /dev/null
+++ b/wake_alarm/tests/test_trigger_clamp.py
@@ -0,0 +1,66 @@
+"""Tests for the PC-side clamp — a synced alarm may never land later."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from unittest.mock import patch
+
+import pytest
+
+from wake_alarm._constants import FALLBACK_ALARM_HOUR
+from wake_alarm._sync import Alarm
+from wake_alarm._trigger import (
+ _clamp_to_baseline,
+)
+
+
+def _at(hour: int, minute: int) -> datetime:
+ """Return a Sunday at the given local wall-clock time, timezone-aware.
+
+ Built the same way production does — naive wall clock, then astimezone()
+ to attach the system offset — so the tests exercise the real comparison
+ semantics rather than a hand-picked fixed offset.
+ """
+ return datetime(2026, 7, 19, hour, minute, tzinfo=None).astimezone()
+
+
+@pytest.fixture(autouse=True)
+def _ungated() -> None:
+ """Open every gate unless a test closes one.
+
+ ``should_refire`` is stubbed here too: unstubbed it reads the *real*
+ session record on the host, so a test run started while an alarm was
+ actually ringing would silently invert half these assertions.
+ """
+ with (
+ patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
+ patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
+ patch("wake_alarm._trigger.should_refire", return_value=False),
+ ):
+ yield
+
+
+class TestClampToBaseline:
+ """The PC-side watchdog: a synced alarm may never land later than 08:00."""
+
+ def test_an_earlier_alarm_passes_through_untouched(self) -> None:
+ """The phone ratchets earlier, so earlier values are legitimate."""
+ alarm = Alarm(6, 30, "remote")
+ assert _clamp_to_baseline(alarm) == alarm
+
+ def test_the_baseline_itself_passes_through(self) -> None:
+ """Equal to the baseline is not later than it."""
+ alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote")
+ assert _clamp_to_baseline(alarm) == alarm
+
+ def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
+ """The one direction the design forbids must not be reachable."""
+ clamped = _clamp_to_baseline(Alarm(13, 0, "remote"))
+ assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
+ assert clamped.source == "clamped-remote"
+
+ def test_a_later_minute_within_the_baseline_hour_is_clamped(self) -> None:
+ """08:30 is later than 08:00, so the minute matters too."""
+ clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache"))
+ assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
+ assert clamped.source == "clamped-cache"