From 33e19426f251f1a0afa5c59f93bc0afc54f3d5f5 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:19:15 +0100 Subject: [PATCH 1/5] Keep alarms armed when the app stays closed Alarms were armed 3 days ahead and only while the app was open, so they stopped 3 days after the last open, and a time zone change left them at the old zone's time. Android triggers now start a headless FlutterEngine that runs the same Dart sync: a dose alarm firing, boot, app update, TIMEZONE_CHANGED, TIME_SET and a daily safety net for schedules with no alarm for days. The scheduling rules stay in Dart only. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 25 +++ android/app/src/main/AndroidManifest.xml | 3 + .../dev/susiee/dosekeeper/AlarmChannel.kt | 122 +++++++++++++ .../dev/susiee/dosekeeper/AlarmReceiver.kt | 9 + .../dev/susiee/dosekeeper/BackgroundSync.kt | 161 +++++++++++++++++ .../dev/susiee/dosekeeper/BootReceiver.kt | 18 +- .../dev/susiee/dosekeeper/MainActivity.kt | 95 +--------- lib/main.dart | 25 +++ lib/services/alarm_bridge.dart | 6 + lib/services/scheduler.dart | 5 + pubspec.yaml | 1 + test/rearm_test.dart | 167 ++++++++++++++++++ 12 files changed, 544 insertions(+), 93 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt create mode 100644 android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt create mode 100644 test/rearm_test.dart diff --git a/AGENTS.md b/AGENTS.md index ab8f2d6..b7b07cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,31 @@ feature. Do not trade it away for elegance or convenience. ## Current state (2026-11-04) +2026-11-09: **alarms stay armed with the app never opened, and follow time zone changes.** +Before, alarms were armed only 3 days ahead and only when the app was open, so they stopped +3 days after the last open. Now Android triggers start a headless FlutterEngine +(`BackgroundSync.kt`) that runs the same Dart `DoseScheduler.sync()` (entry point +`backgroundSync` in `lib/main.dart`). One copy of the rules, in Dart: don't port them to +Kotlin. The horizon is still 3 days. Triggers: a dose alarm firing (`AlarmReceiver`, 3 s +later so ringing comes first), boot / app update (`BootReceiver`, after its native +`rearmAll`), `TIMEZONE_CHANGED` / `TIME_SET`, and a daily safety net (`setAndAllowWhileIdle`, +for schedules with no alarm for days, e.g. weekly). The method-channel calls sync needs moved +to `AlarmChannel.kt`, shared with `MainActivity`. A time zone change works through the +existing orphan cleanup: doses off the new wall clock are cancelled and re-created. +**No `goAsync()` in `AlarmReceiver`, on purpose:** Android holds the app's next broadcast until +the last one finishes, which rang a second dose due at the same moment 3 s late. +Tested on emulators (not a real phone yet): Android 16 debug and Android 12 release, app +process killed and never opened. A fired alarm armed a dose the clock-change sync couldn't +reach; reboot brought every alarm back; New York and Tokyo zones moved them to the right wall +clock; a 5-day clock jump re-armed past the old window; Snooze and Taken caused no double alarm +(Android 16). How: `adb shell settings put global auto_time 0` (and `auto_time_zone 0`), then +`adb shell cmd alarm set-time ` / `cmd alarm set-timezone `, and put both back. +**Force-stop can't be fixed:** Android deletes every alarm of a force-stopped app (seen: 0 +left) until it's opened again. On ColorOS, check swiping the app away doesn't do the same. +Known and left alone: after flying, a dose already taken can come back at the new zone's same +wall-clock time (a west flight rings again; an east flight can show it as missed). That's the +wall-clock design, and opening the app always did the same. + 2026-11-08: **an alarm screen drops doses answered elsewhere, and closes when none are left.** Found on the Android 12 emulator: after its doses were marked Taken on Today, the alarm screen stayed open behind, and Snooze there re-armed alarms for taken doses (they rang again diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6fbd2a9..6c1256a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -78,6 +78,9 @@ + + + diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt new file mode 100644 index 0000000..20f5a1a --- /dev/null +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt @@ -0,0 +1,122 @@ +package dev.susiee.dosekeeper + +import android.content.Context +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * The method-channel calls a background sync needs (see BackgroundSync): everything + * DoseScheduler.sync() can invoke. They live here, not in MainActivity, so a headless + * FlutterEngine -- started while the app is closed, with no activity -- can serve the + * exact same Dart code path as the foreground app. Returns true when the call was one + * of these (handled or errored), false so the caller's own `when` can take it. + */ +object AlarmChannel { + + fun handle(context: Context, call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "scheduleDose" -> { + val doseId = (call.argument("doseId")!!).toLong() + val triggerAt = (call.argument("triggerAt")!!).toLong() + // Sync read this dose as upcoming, then its alarm fired while sync was + // still running: a fired record already exists for it. Arming the now-past + // time would ring it a second time at once -- so count it as done. + val fired = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) + .contains("${AlarmReceiver.KEY_FIRED_PREFIX}$doseId") + result.success( + fired && triggerAt <= System.currentTimeMillis() || + AlarmScheduler.schedule( + context = context, + doseId = doseId, + triggerAtMillis = triggerAt, + medName = call.argument("medName") ?: "Medication", + dosage = call.argument("dosage") ?: "", + ), + ) + } + + "cancelDose" -> { + AlarmScheduler.cancel(context, (call.argument("doseId")!!).toLong()) + result.success(true) + } + + /** Outcomes the native alarm screen recorded while Dart wasn't running. */ + "drainPendingActions" -> { + val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) + val out = mutableListOf>() + val editor = prefs.edit() + prefs.all.forEach { (key, value) -> + if (key.startsWith("pending_action_") && !key.startsWith("pending_action_at_")) { + val doseId = key.removePrefix("pending_action_").toLongOrNull() + if (doseId != null) { + out.add( + mapOf( + "doseId" to doseId, + "action" to (value as? String ?: "TAKEN"), + "at" to prefs.getLong("pending_action_at_$doseId", 0L), + ), + ) + editor.remove(key).remove("pending_action_at_$doseId") + } + } + } + editor.apply() + result.success(out) + } + + /** + * When each alarm actually fired, per AlarmReceiver.KEY_FIRED_PREFIX. + * Independent of drainPendingActions: that records what the user *did* + * about an alarm, this records whether the alarm *happened* at all -- + * the distinction that makes a missed dose attributable instead of a + * mystery. + */ + "drainFiredEvents" -> { + val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) + val out = mutableListOf>() + val editor = prefs.edit() + prefs.all.forEach { (key, value) -> + if (key.startsWith(AlarmReceiver.KEY_FIRED_PREFIX)) { + val doseId = key.removePrefix(AlarmReceiver.KEY_FIRED_PREFIX).toLongOrNull() + if (doseId != null && value is Long) { + out.add(mapOf("doseId" to doseId, "firedAt" to value)) + editor.remove(key) + } + } + } + editor.apply() + result.success(out) + } + + /** An overdue dose exists -- show/update the reminder. */ + "updateOutstandingNotification" -> { + OutstandingNotifier.show( + context, + title = call.argument("title") ?: "Missed dose", + text = call.argument("text") ?: "", + shownAt = call.argument("shownAt")?.toLong() ?: 0L, + // Exactly one missed dose: the notification gets Taken/Skip buttons. + doseId = call.argument("doseId")?.toLong(), + ) + result.success(true) + } + + /** Doses overdue by this time were swiped away by the user; 0 if none. */ + "outstandingDismissedThrough" -> { + result.success( + context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) + .getLong(OutstandingNotifier.KEY_DISMISSED_THROUGH, 0L), + ) + } + + /** No more overdue doses -- clear the reminder. */ + "clearOutstandingNotification" -> { + OutstandingNotifier.clear(context) + result.success(true) + } + + else -> return false + } + return true + } +} diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt index 645bdd4..719c0f8 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt @@ -49,10 +49,19 @@ class AlarmReceiver : BroadcastReceiver() { intent.getStringExtra(AlarmScheduler.EXTRA_DOSAGE) ?: "", ) } + + // Ring first: nothing may delay it, so the sync that tops the horizon up waits + // a few seconds. Doses are at whole minutes anyway, so a sync a few seconds + // after one can't straddle the next. No goAsync() here: Android holds this app's + // next broadcast until this one finishes, which rang a second dose due at the + // same moment 3 s late (seen on Android 16). The ringing service keeps the + // process alive instead. + BackgroundSync.request(context, delayMs = RING_FIRST_DELAY_MS) } companion object { private const val TAG = "DoseKeeper/Receiver" + private const val RING_FIRST_DELAY_MS = 3_000L const val KEY_FIRED_PREFIX = "fired_at_" } } diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt new file mode 100644 index 0000000..f66ec3f --- /dev/null +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt @@ -0,0 +1,161 @@ +package dev.susiee.dosekeeper + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel + +/** + * Runs the same Dart DoseScheduler.sync() the app runs when open, but from a headless + * FlutterEngine started by Android triggers: an alarm firing, a time/time-zone change, + * a daily safety net, a reboot. Dart owns the schedule and the database, so the + * scheduling rules are NOT ported to Kotlin -- this only gives Dart a chance to run + * without the user opening the app. Without it, alarms (armed [DoseScheduler.horizon] + * = 3 days ahead) simply stop if the app isn't opened for 3 days, and stay on the old + * wall-clock time after a time-zone change. + * + * Main thread only: every field is touched from the main looper. + */ +object BackgroundSync { + private const val TAG = "DoseKeeper/BgSync" + + /** BroadcastReceiver.goAsync() allows ~10s (foreground broadcasts); finish before. */ + private const val PENDING_TIMEOUT_MS = 9_000L + + /** A stuck Dart must never block later syncs. */ + private const val RUN_TIMEOUT_MS = 60_000L + + const val ACTION_DAILY_SYNC = "dev.susiee.dosekeeper.DAILY_SYNC" + + private val handler = Handler(Looper.getMainLooper()) + private val pendings = mutableListOf() + private var engine: FlutterEngine? = null + private var running = false + private var again = false + private var timeout: Runnable? = null + private var ctx: Context? = null + + /** + * Asks for a background sync, finishing [pending] (from goAsync(), if any) within + * the broadcast limit no matter how the run goes. [delayMs] delays the engine start, + * e.g. so a ringing alarm always goes first. + */ + fun request(context: Context, pending: BroadcastReceiver.PendingResult? = null, delayMs: Long = 0) { + val ctx = context.applicationContext + armSafetyNet(ctx) // first: nothing below may get a chance to fail before this + if (pending != null) { + pendings.add(pending) + // TIME_SET/TIMEZONE_CHANGED are foreground broadcasts with a 10 s limit: never + // let a long sync keep the process alive on this broadcast's tab. + handler.postDelayed({ + if (pendings.remove(pending)) pending.finish() + }, PENDING_TIMEOUT_MS) + } + handler.postDelayed({ + if (running) { + // A run is in progress and may have read the old clock; rerun after it. + again = true + } else { + start(ctx) + } + }, delayMs) + } + + private fun start(ctx: Context) { + running = true // before the async load, so a second request can't start a second engine + val t = Runnable { + Log.e(TAG, "background sync timed out; forcing the run to finish") + finish() + } + timeout = t + handler.postDelayed(t, RUN_TIMEOUT_MS) + this.ctx = ctx + try { + val loader = FlutterInjector.instance().flutterLoader() + loader.startInitialization(ctx) + // The engine only after this: FlutterEngine() would otherwise load Flutter + // synchronously, on the main thread a ringing alarm needs. + loader.ensureInitializationCompleteAsync(ctx, null, handler) { + if (timeout === t) launch(ctx, loader.findAppBundlePath()) // else timed out already + } + } catch (e: Exception) { + Log.e(TAG, "couldn't start the background sync engine", e) + finish() + } + } + + private fun launch(ctx: Context, bundlePath: String) { + try { + val e = FlutterEngine(ctx) // plugins auto-register; sqflite needs that + engine = e + MethodChannel(e.dartExecutor.binaryMessenger, "dev.susiee.dosekeeper/alarms") + .setMethodCallHandler { call, result -> + if (call.method == "backgroundSyncDone") { + result.success(true) + // Posted: not from inside the engine's own callback. Checked: if the + // timeout got in first, this must not end a rerun started since. + handler.post { if (engine === e) finish() } + } else if (!AlarmChannel.handle(ctx, call, result)) { + result.notImplemented() + } + } + e.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint(bundlePath, "backgroundSync"), + ) + } catch (e: Exception) { + Log.e(TAG, "couldn't start the background sync engine", e) + finish() + } + } + + /** Tears the run down: engine, timeout, and every pending broadcast result. */ + private fun finish() { + engine?.destroy() + engine = null + running = false + timeout?.let { handler.removeCallbacks(it) } + timeout = null + pendings.forEach { it.finish() } + pendings.clear() + if (again) { + again = false + // Rerun once. The triggering broadcasts' pending results are already + // finished, so the rerun needs no goAsync bookkeeping. + ctx?.let { start(it) } + } + } + + /** + * Tops the armed alarms up at least daily even when nothing else fires. A + * weekly-only medication can have no alarm for days, so no alarm fires to trigger + * a sync; inexact needs no permission, and is deliberately not setAlarmClock (that + * would show the user a fake next alarm). Worst case Android stretches it to + * ~42 h, still inside the 72 h horizon. + */ + fun armSafetyNet(context: Context) { + try { + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pi = PendingIntent.getBroadcast( + context, + 4245, + Intent(context, BootReceiver::class.java).setAction(ACTION_DAILY_SYNC), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + am.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + 24 * 60 * 60 * 1000L, + pi, + ) + } catch (e: RuntimeException) { + Log.e(TAG, "couldn't arm the daily sync safety net", e) + } + } +} diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt index 39dcd41..dec15a5 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt @@ -8,15 +8,27 @@ import android.util.Log /** * Alarms do not survive a reboot (or, on some OEM builds, an app update). This re-arms * them natively from AlarmScheduler's own record of what was armed -- without waiting - * for the user to open the app, which is the only time Dart runs. + * for the user to open the app, which used to be the only time Dart ran. + * + * Time/time-zone changes and the daily safety net arrive here too. Their alarms are + * still armed (only the wall-clock meaning changed, or nothing changed), so they skip + * the re-arm and only kick off a background Dart sync -- which re-materialises doses + * on the new clock and tops the horizon up (see BackgroundSync). * * ColorOS only delivers these broadcasts to apps allowed to "auto-launch"; the user has * to allow that for DoseKeeper in the phone's settings (see README). */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - Log.i(TAG, "boot/replace broadcast: ${intent.action}") - AlarmScheduler.rearmAll(context) + Log.i(TAG, "broadcast: ${intent.action}") + when (intent.action) { + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_TIME_CHANGED, + BackgroundSync.ACTION_DAILY_SYNC, + -> Unit // alarms are still armed; only Dart's picture needs refreshing + else -> AlarmScheduler.rearmAll(context) // boot/replace: alarms are gone + } + BackgroundSync.request(context, goAsync()) } companion object { diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt index 2a9a39d..0698ce4 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt @@ -32,24 +32,14 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + BackgroundSync.armSafetyNet(this) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) .setMethodCallHandler { call, result -> + // The calls a background sync can make (AlarmChannel) work with just a + // context; only the rest below actually needs an activity. + if (AlarmChannel.handle(this, call, result)) return@setMethodCallHandler when (call.method) { - "scheduleDose" -> result.success( - AlarmScheduler.schedule( - context = this, - doseId = (call.argument("doseId")!!).toLong(), - triggerAtMillis = (call.argument("triggerAt")!!).toLong(), - medName = call.argument("medName") ?: "Medication", - dosage = call.argument("dosage") ?: "", - ), - ) - - "cancelDose" -> { - AlarmScheduler.cancel(this, (call.argument("doseId")!!).toLong()) - result.success(true) - } - "canScheduleExact" -> result.success(AlarmScheduler.canScheduleExact(this)) "openExactAlarmSettings" -> { @@ -162,54 +152,6 @@ class MainActivity : FlutterActivity() { result.success(true) } - /** Outcomes the native alarm screen recorded while Dart wasn't running. */ - "drainPendingActions" -> { - val prefs = getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) - val out = mutableListOf>() - val editor = prefs.edit() - prefs.all.forEach { (key, value) -> - if (key.startsWith("pending_action_") && !key.startsWith("pending_action_at_")) { - val doseId = key.removePrefix("pending_action_").toLongOrNull() - if (doseId != null) { - out.add( - mapOf( - "doseId" to doseId, - "action" to (value as? String ?: "TAKEN"), - "at" to prefs.getLong("pending_action_at_$doseId", 0L), - ), - ) - editor.remove(key).remove("pending_action_at_$doseId") - } - } - } - editor.apply() - result.success(out) - } - - /** - * When each alarm actually fired, per AlarmReceiver.KEY_FIRED_PREFIX. - * Independent of drainPendingActions: that records what the user *did* - * about an alarm, this records whether the alarm *happened* at all -- - * the distinction that makes a missed dose attributable instead of a - * mystery. - */ - "drainFiredEvents" -> { - val prefs = getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) - val out = mutableListOf>() - val editor = prefs.edit() - prefs.all.forEach { (key, value) -> - if (key.startsWith(AlarmReceiver.KEY_FIRED_PREFIX)) { - val doseId = key.removePrefix(AlarmReceiver.KEY_FIRED_PREFIX).toLongOrNull() - if (doseId != null && value is Long) { - out.add(mapOf("doseId" to doseId, "firedAt" to value)) - editor.remove(key) - } - } - } - editor.apply() - result.success(out) - } - "appVersion" -> { result.success(packageManager.getPackageInfo(packageName, 0).versionName) } @@ -239,33 +181,6 @@ class MainActivity : FlutterActivity() { } } - /** An overdue dose exists -- show/update the reminder. */ - "updateOutstandingNotification" -> { - OutstandingNotifier.show( - this, - title = call.argument("title") ?: "Missed dose", - text = call.argument("text") ?: "", - shownAt = call.argument("shownAt")?.toLong() ?: 0L, - // Exactly one missed dose: the notification gets Taken/Skip buttons. - doseId = call.argument("doseId")?.toLong(), - ) - result.success(true) - } - - /** Doses overdue by this time were swiped away by the user; 0 if none. */ - "outstandingDismissedThrough" -> { - result.success( - getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) - .getLong(OutstandingNotifier.KEY_DISMISSED_THROUGH, 0L), - ) - } - - /** No more overdue doses -- clear the reminder. */ - "clearOutstandingNotification" -> { - OutstandingNotifier.clear(this) - result.success(true) - } - // --- backups (see BackupStore for why a user-picked folder) --- "backupStatus" -> io(result) { BackupStore.status(this) } diff --git a/lib/main.dart b/lib/main.dart index 3745aef..14dfa65 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show DartPluginRegistrant; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -10,6 +12,29 @@ import 'ui/today_page.dart'; void main() => runApp(const DoseKeeperApp()); +/// Entry point for the headless engine BackgroundSync.kt starts when Android gives us +/// a trigger (an alarm fired, the clock or time zone changed, the daily safety net, a +/// reboot) while the app isn't open. It runs the exact same [DoseScheduler.sync] the +/// foreground app runs -- the scheduling rules live only in Dart. It must not touch +/// UI: there is no screen, and no widgets are ever built. +/// +/// The database is deliberately not closed: sqflite shares one native handle per path +/// across engines, so closing it here would break the foreground app's own handle. +@pragma('vm:entry-point') +Future backgroundSync() async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + try { + await DoseScheduler().sync(); + } catch (e, st) { + debugPrint('backgroundSync failed: $e\n$st'); + } finally { + // Always answer, even on failure: the native side tears the engine down on this + // call (and has a hard timeout for the case where even this never arrives). + await AlarmBridge.backgroundSyncDone(); + } +} + class DoseKeeperApp extends StatelessWidget { const DoseKeeperApp({super.key}); diff --git a/lib/services/alarm_bridge.dart b/lib/services/alarm_bridge.dart index 87005b5..e8c7889 100644 --- a/lib/services/alarm_bridge.dart +++ b/lib/services/alarm_bridge.dart @@ -86,6 +86,12 @@ class AlarmBridge { static Future clearOutstandingNotification() => _channel.invokeMethod('clearOutstandingNotification'); + /// Tells the native side a headless backgroundSync run is over, so it can tear the + /// engine down. Only the backgroundSync entry point calls this; the foreground app + /// never does. + static Future backgroundSyncDone() => + _channel.invokeMethod('backgroundSyncDone'); + /// The installed version as Android reports it, e.g. "1.1.0" or "1.1.0-debug". static Future appVersion() async => await _channel.invokeMethod('appVersion') ?? 'Unknown'; diff --git a/lib/services/scheduler.dart b/lib/services/scheduler.dart index 433294f..9b14355 100644 --- a/lib/services/scheduler.dart +++ b/lib/services/scheduler.dart @@ -12,6 +12,11 @@ class DoseScheduler { /// How far ahead to materialise doses and arm alarms. Android tolerates a few dozen /// pending alarms comfortably; this keeps us well inside that. + /// + /// Must stay longer than any gap between syncs. Besides every app open, sync also + /// runs natively (BackgroundSync.kt) every time an alarm fires, on every clock or + /// time-zone change, on reboot, and from a daily safety-net alarm, so alarms stay + /// armed even if the app is never opened. static const horizon = Duration(days: 3); /// [minutes] past midnight on [day], by the wall clock. Built from date parts, never diff --git a/pubspec.yaml b/pubspec.yaml index 2b69b53..8786fb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 + sqflite_common_ffi: ^2.3.0 flutter: uses-material-design: true diff --git a/test/rearm_test.dart b/test/rearm_test.dart new file mode 100644 index 0000000..32f0668 --- /dev/null +++ b/test/rearm_test.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:dosekeeper/data/database.dart'; +import 'package:dosekeeper/models/medication.dart'; +import 'package:dosekeeper/services/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +// What Android's background sync (BackgroundSync.kt -> DoseScheduler.sync) must +// re-arm. Each test runs sync() against a real SQLite database (via sqflite FFI) +// with the native alarm channel faked out, recording what would be armed/cancelled. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Fake native alarm state, read and written by the mock channel handler. + var armed = {}; // doseId -> triggerAt millis + var cancelled = []; + var scheduleCalls = 0; + var pendingActions = >[]; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + // Temp databases path, so nothing touches a real one. + await databaseFactory.setDatabasesPath( + (await Directory.systemTemp.createTemp('dosekeeper_rearm_test')).path); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.susiee.dosekeeper/alarms'), + (call) async { + final args = call.arguments is Map + ? Map.from(call.arguments as Map) + : const {}; + switch (call.method) { + case 'scheduleDose': + scheduleCalls++; + armed[args['doseId'] as int] = (args['triggerAt'] as num).toInt(); + return true; + case 'cancelDose': + final id = args['doseId'] as int; + armed.remove(id); + cancelled.add(id); + return null; + case 'drainPendingActions': + final out = pendingActions; + pendingActions = []; + return out; + case 'drainFiredEvents': + return []; + case 'outstandingDismissedThrough': + return 0; + default: + return null; + } + }, + ); + }); + + setUp(() async { + armed = {}; + cancelled = []; + scheduleCalls = 0; + pendingActions = []; + // DoseDatabase is a singleton; isolate tests by clearing its rows instead + // of re-opening at a new path. + final conn = await DoseDatabase.instance.database; + await conn.delete('doses'); + await conn.delete('medications'); + }); + + Future addMed({ + List timesOfDay = const [540], // 09:00 + Set daysOfWeek = Medication.everyDay, + }) async => + await DoseDatabase.instance.upsertMedication(Medication( + name: 'Test med', + timesOfDay: timesOfDay, + daysOfWeek: daysOfWeek, + )); + + final db = DoseDatabase.instance; + final scheduler = DoseScheduler(); + int ms(DateTime t) => t.millisecondsSinceEpoch; + + test('top-up past the old window arms a dose beyond 3 days', () async { + await addMed(); + // Day 0 (Mon Nov 2): only days 0..3 are materialised and armed. + await scheduler.sync(now: DateTime(2026, 11, 2, 10, 0)); + expect(armed.values.any((t) => t >= ms(DateTime(2026, 11, 6, 9, 0))), isFalse, + reason: 'nothing beyond the 3-day horizon can be armed from day 0'); + // Day 5: the window slid to days 5..8, so day 7 09:00 must be armed now. + await scheduler.sync(now: DateTime(2026, 11, 7, 10, 0)); + expect(armed.containsValue(ms(DateTime(2026, 11, 9, 9, 0))), isTrue, + reason: 'day 7 09:00 (5 days past day 0) must be re-armed'); + }); + + test('weekly med armed only once its next occurrence is within horizon', () async { + await addMed(daysOfWeek: {1}); // Mondays + // One sync per day, Tue Nov 10 .. Tue Nov 17. Next Monday is Nov 16: + // inside [now, now + 3 days) only from Fri Nov 13 on. + for (var i = 0; i < 8; i++) { + await scheduler.sync(now: DateTime(2026, 11, 10 + i, 10, 0)); + expect(armed.containsValue(ms(DateTime(2026, 11, 16, 9, 0))), i >= 3, + reason: 'day offset $i: Nov 16 crosses into the horizon at Nov 13'); + } + }); + + test('a moved-wall-clock dose off its slot is cancelled, deleted, replaced', () async { + final medId = await addMed(); + // A future pending dose an hour off the 09:00 slot -- what a time-zone + // change leaves behind. Native already armed it, so record that. + final movedId = await db.ensureDose(medId, DateTime(2026, 11, 10, 10, 0)); + armed[movedId!] = ms(DateTime(2026, 11, 10, 10, 0)); + await scheduler.sync(now: DateTime(2026, 11, 9, 12, 0)); + expect(cancelled, contains(movedId)); + expect(await db.dose(movedId), isNull, + reason: 'the off-slot dose row must be gone'); + expect(armed.containsKey(movedId), isFalse); + expect(armed.containsValue(ms(DateTime(2026, 11, 10, 9, 0))), isTrue, + reason: 'the correct 09:00 dose is armed instead'); + }); + + test('three syncs with the same now never double-arm', () async { + await addMed(); + final now = DateTime(2026, 11, 9, 10, 0); + for (var i = 0; i < 3; i++) { + await scheduler.sync(now: now); + } + final window = await db.dosesBetween(now, now.add(DoseScheduler.horizon)); + final pendingIds = + window.where((d) => d.status == DoseStatus.pending).map((d) => d.id).toSet(); + expect(armed.keys.toSet(), pendingIds, + reason: 'one armed alarm per pending dose in the window, no extras'); + expect(armed.length, pendingIds.length); + expect(armed.values.toSet().length, armed.length, + reason: 'each dose id has exactly one trigger time'); + expect(scheduleCalls, pendingIds.length * 3, + reason: 'each sync re-arms each dose exactly once'); + }); + + test('a pending dose a minute past due is not re-armed, cancelled or deleted', () async { + final medId = await addMed(); + // 09:00 dose, sync at 09:01: the alarm just fired (or was snoozed) and the + // user hasn't answered yet. Nothing must touch it. + final id = await db.ensureDose(medId, DateTime(2026, 11, 9, 9, 0)); + await scheduler.sync(now: DateTime(2026, 11, 9, 9, 1)); + expect(armed.containsKey(id), isFalse, + reason: 'a passed dose must not get a scheduleDose call'); + expect(cancelled, isNot(contains(id))); + expect((await db.dose(id!))?.status, DoseStatus.pending, + reason: 'still pending, not deleted'); + }); + + test('a queued TAKEN action marks the dose and keeps it unarmed', () async { + final medId = await addMed(); + final now = DateTime(2026, 11, 9, 8, 0); + final id = await db.ensureDose(medId, DateTime(2026, 11, 9, 9, 0)); + pendingActions = [ + {'doseId': id, 'action': 'TAKEN', 'at': ms(DateTime(2026, 11, 9, 9, 0))}, + ]; + await scheduler.sync(now: now); + expect((await db.dose(id!))?.status, DoseStatus.taken); + expect(armed.containsKey(id), isFalse, + reason: 'a taken dose must not get an alarm'); + }); +} \ No newline at end of file From 1219b601b623dbe38c2c1a34b760620a7af7155e Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:24:20 +0100 Subject: [PATCH 2/5] Keep the test SQLite package from touching the app build sqlite3 3.x has a native build step that also runs for the Android build, so the app build depended on a download from GitHub, and a 504 there failed CI. 2.x has no build step. Only tests use it. Co-Authored-By: Claude Opus 5.5 --- pubspec.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index 8786fb8..30ff165 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,9 @@ dev_dependencies: flutter_lints: ^6.0.0 sqflite_common_ffi: ^2.3.0 + # Stay on 2.x: 3.x has a native build step that also runs for the Android app build, + # making it depend on a download from GitHub (a 504 there failed CI). Tests only need this. + sqlite3: ^2.9.0 flutter: uses-material-design: true From 796fb7f3828819c6ed91c85bf2cd6cf29f6504d5 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:27:23 +0100 Subject: [PATCH 3/5] BootReceiver: ignore actions outside the manifest filter The receiver is exported, so any app could send it an unknown action, which was treated as a boot and started a headless engine. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt index dec15a5..3278db6 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt @@ -26,7 +26,15 @@ class BootReceiver : BroadcastReceiver() { Intent.ACTION_TIME_CHANGED, BackgroundSync.ACTION_DAILY_SYNC, -> Unit // alarms are still armed; only Dart's picture needs refreshing - else -> AlarmScheduler.rearmAll(context) // boot/replace: alarms are gone + + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + "android.intent.action.QUICKBOOT_POWERON", + "com.htc.intent.action.QUICKBOOT_POWERON", + -> AlarmScheduler.rearmAll(context) // boot/replace: alarms are gone + + // Exported, so any app can send this anything. Keep in step with the manifest. + else -> return } BackgroundSync.request(context, goAsync()) } From 572fcae33bed58ff326f936129e2094705cb169a Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:33:29 +0100 Subject: [PATCH 4/5] Keep Taken/Skip records until the database has them Records were deleted when read, before the status was saved. A sync dying in between lost the answer, and background sync can now die with no screen open. A dose wrongly shown as missed can get taken twice. They now go only after the save, and only if unchanged since the read. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 ++- .../dev/susiee/dosekeeper/AlarmChannel.kt | 23 +++++++++--- .../susiee/dosekeeper/OutstandingNotifier.kt | 2 +- lib/services/alarm_bridge.dart | 12 +++++-- lib/services/scheduler.dart | 17 ++++----- test/rearm_test.dart | 36 ++++++++++++++++--- 6 files changed, 74 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7b07cf..72c7bb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,10 @@ clock; a 5-day clock jump re-armed past the old window; Snooze and Taken caused `adb shell cmd alarm set-time ` / `cmd alarm set-timezone `, and put both back. **Force-stop can't be fixed:** Android deletes every alarm of a force-stopped app (seen: 0 left) until it's opened again. On ColorOS, check swiping the app away doesn't do the same. -Known and left alone: after flying, a dose already taken can come back at the new zone's same +Alarm-screen and notification answers now stay stored until the database has them +(`readPendingActions`, then `ackPendingAction`), so a sync dying midway can't lose a Taken. +Known and left alone: a sync already running when Taken is tapped on a future dose can re-arm +it (sub-second window, one extra ring, the app's own sync can do the same). Also, after flying, a dose already taken can come back at the new zone's same wall-clock time (a west flight rings again; an east flight can show it as missed). That's the wall-clock design, and opening the app always did the same. diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt index 20f5a1a..417ddcc 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt @@ -41,10 +41,9 @@ object AlarmChannel { } /** Outcomes the native alarm screen recorded while Dart wasn't running. */ - "drainPendingActions" -> { + "readPendingActions" -> { val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) val out = mutableListOf>() - val editor = prefs.edit() prefs.all.forEach { (key, value) -> if (key.startsWith("pending_action_") && !key.startsWith("pending_action_at_")) { val doseId = key.removePrefix("pending_action_").toLongOrNull() @@ -56,17 +55,31 @@ object AlarmChannel { "at" to prefs.getLong("pending_action_at_$doseId", 0L), ), ) - editor.remove(key).remove("pending_action_at_$doseId") } } } - editor.apply() result.success(out) } + /** + * The database has this outcome, so the record can go. Not removed on read: a + * sync dying in between (it can run with no screen open) would lose a Taken, + * and a dose shown as missed can get taken twice. Only if it's still the same + * record: the user may have answered again since it was read. + */ + "ackPendingAction" -> { + val doseId = (call.argument("doseId")!!).toLong() + val at = (call.argument("at")!!).toLong() + val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE) + if (prefs.getLong("pending_action_at_$doseId", 0L) == at) { + prefs.edit().remove("pending_action_$doseId").remove("pending_action_at_$doseId").apply() + } + result.success(true) + } + /** * When each alarm actually fired, per AlarmReceiver.KEY_FIRED_PREFIX. - * Independent of drainPendingActions: that records what the user *did* + * Independent of readPendingActions: that records what the user *did* * about an alarm, this records whether the alarm *happened* at all -- * the distinction that makes a missed dose attributable instead of a * mystery. diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/OutstandingNotifier.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/OutstandingNotifier.kt index 9b9a701..c276b7b 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/OutstandingNotifier.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/OutstandingNotifier.kt @@ -24,7 +24,7 @@ object OutstandingNotifier { private const val NOTIFICATION_ID = 4243 const val KEY_DISMISSED_THROUGH = "outstanding_dismissed_through" const val EXTRA_ACTION = "outstanding_action" - const val ACTION_TAKEN = "TAKEN" // the strings Dart's drainPendingActions expects + const val ACTION_TAKEN = "TAKEN" // the strings Dart's readPendingActions expects const val ACTION_SKIPPED = "SKIPPED" private const val REQ_TAKEN = 1 private const val REQ_SKIPPED = 2 diff --git a/lib/services/alarm_bridge.dart b/lib/services/alarm_bridge.dart index e8c7889..393d6df 100644 --- a/lib/services/alarm_bridge.dart +++ b/lib/services/alarm_bridge.dart @@ -130,13 +130,21 @@ class AlarmBridge { /// Outcomes the native alarm screen (or a missed-notification Taken/Skip button) /// recorded while Dart was not running. - static Future> drainPendingActions() async { - final raw = await _channel.invokeMethod>('drainPendingActions'); + /// They stay stored until [ackPendingAction]. + static Future> readPendingActions() async { + final raw = await _channel.invokeMethod>('readPendingActions'); return (raw ?? []) .map((e) => PendingAlarmAction.fromMap(Map.from(e as Map))) .toList(); } + /// Call once the database has [action], so an interrupted sync can't lose it. + static Future ackPendingAction(PendingAlarmAction action) => + _channel.invokeMethod('ackPendingAction', { + 'doseId': action.doseId, + 'at': action.at.millisecondsSinceEpoch, + }); + /// When each alarm actually fired (recorded by AlarmReceiver the instant the OS /// delivers it), independent of whether/how the user responded. This is what makes /// a missed dose attributable: "the alarm never rang" versus "it rang and was diff --git a/lib/services/scheduler.dart b/lib/services/scheduler.dart index 9b14355..3b6ab97 100644 --- a/lib/services/scheduler.dart +++ b/lib/services/scheduler.dart @@ -167,16 +167,17 @@ class DoseScheduler { /// Applies outcomes the native alarm screen (or a missed-notification Taken/Skip /// button) recorded while Dart wasn't running. Future applyPendingAlarmActions() async { - final pending = await AlarmBridge.drainPendingActions(); + final pending = await AlarmBridge.readPendingActions(); for (final action in pending) { - final dose = await _db.dose(action.doseId); - if (dose == null) continue; - if (action.isTaken) { - await _db.setDoseStatus(action.doseId, DoseStatus.taken, at: action.at); - } else if (action.isSkipped) { - await _db.setDoseStatus(action.doseId, DoseStatus.skipped, at: action.at); + if (await _db.dose(action.doseId) != null) { + if (action.isTaken) { + await _db.setDoseStatus(action.doseId, DoseStatus.taken, at: action.at); + } else if (action.isSkipped) { + await _db.setDoseStatus(action.doseId, DoseStatus.skipped, at: action.at); + } + // SNOOZE re-arms itself natively; the dose stays pending on purpose. } - // SNOOZE re-arms itself natively; the dose stays pending on purpose. + await AlarmBridge.ackPendingAction(action); // last: a sync dying before this replays it } } diff --git a/test/rearm_test.dart b/test/rearm_test.dart index 32f0668..3d22eb2 100644 --- a/test/rearm_test.dart +++ b/test/rearm_test.dart @@ -18,6 +18,7 @@ void main() { var cancelled = []; var scheduleCalls = 0; var pendingActions = >[]; + var statusAtAck = {}; // what the database held when acked setUpAll(() async { sqfliteFfiInit(); @@ -42,10 +43,16 @@ void main() { armed.remove(id); cancelled.add(id); return null; - case 'drainPendingActions': - final out = pendingActions; - pendingActions = []; - return out; + case 'readPendingActions': + return pendingActions; + case 'ackPendingAction': + final id = args['doseId'] as int; + statusAtAck[id] = (await DoseDatabase.instance.dose(id))?.status; + pendingActions = [ + for (final a in pendingActions) + if (a['doseId'] != id || a['at'] != args['at']) a, + ]; + return true; case 'drainFiredEvents': return []; case 'outstandingDismissedThrough': @@ -62,6 +69,7 @@ void main() { cancelled = []; scheduleCalls = 0; pendingActions = []; + statusAtAck = {}; // DoseDatabase is a singleton; isolate tests by clearing its rows instead // of re-opening at a new path. final conn = await DoseDatabase.instance.database; @@ -164,4 +172,24 @@ void main() { expect(armed.containsKey(id), isFalse, reason: 'a taken dose must not get an alarm'); }); + + test('a Taken record is removed only after the database has it', () async { + final medId = await addMed(); + final id = await db.ensureDose(medId, DateTime(2026, 11, 9, 9, 0)); + pendingActions = [ + {'doseId': id, 'action': 'TAKEN', 'at': ms(DateTime(2026, 11, 9, 9, 0))}, + ]; + await scheduler.sync(now: DateTime(2026, 11, 9, 8, 0)); + expect(statusAtAck[id], DoseStatus.taken, + reason: 'acked before the status was saved, a dying sync would lose it'); + expect(pendingActions, isEmpty); + }); + + test('a record for a dose that no longer exists is cleared, not kept forever', () async { + pendingActions = [ + {'doseId': 987654, 'action': 'TAKEN', 'at': ms(DateTime(2026, 11, 9, 9, 0))}, + ]; + await scheduler.sync(now: DateTime(2026, 11, 9, 8, 0)); + expect(pendingActions, isEmpty); + }); } \ No newline at end of file From 18e90b02e58a404a4f153246acb13976c05a0e73 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:48:55 +0100 Subject: [PATCH 5/5] Don't let a firing dose alarm push the daily safety net back Every BackgroundSync.request re-armed the net 24 h out, including the one from AlarmReceiver. That sync can be lost if the process dies once ringing stops, so a daily med could keep moving the net without any sync finishing, until the 3-day horizon ran out. Only BootReceiver's triggers (boot, app update, clock and zone changes, the net itself) and opening the app arm it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- .../src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt | 4 +++- .../app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72c7bb5..8662290 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ Before, alarms were armed only 3 days ahead and only when the app was open, so t Kotlin. The horizon is still 3 days. Triggers: a dose alarm firing (`AlarmReceiver`, 3 s later so ringing comes first), boot / app update (`BootReceiver`, after its native `rearmAll`), `TIMEZONE_CHANGED` / `TIME_SET`, and a daily safety net (`setAndAllowWhileIdle`, -for schedules with no alarm for days, e.g. weekly). The method-channel calls sync needs moved +for schedules with no alarm for days, e.g. weekly). A firing dose alarm must not re-arm that net: +its sync can die with the process, and a daily med would then push the net back forever. The method-channel calls sync needs moved to `AlarmChannel.kt`, shared with `MainActivity`. A time zone change works through the existing orphan cleanup: doses off the new wall clock are cancelled and re-created. **No `goAsync()` in `AlarmReceiver`, on purpose:** Android holds the app's next broadcast until diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt index f66ec3f..9798398 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt @@ -50,7 +50,6 @@ object BackgroundSync { */ fun request(context: Context, pending: BroadcastReceiver.PendingResult? = null, delayMs: Long = 0) { val ctx = context.applicationContext - armSafetyNet(ctx) // first: nothing below may get a chance to fail before this if (pending != null) { pendings.add(pending) // TIME_SET/TIMEZONE_CHANGED are foreground broadcasts with a 10 s limit: never @@ -139,6 +138,9 @@ object BackgroundSync { * a sync; inexact needs no permission, and is deliberately not setAlarmClock (that * would show the user a fake next alarm). Worst case Android stretches it to * ~42 h, still inside the 72 h horizon. + * + * Not re-armed by a firing dose alarm: its sync can be lost if the process dies + * once ringing stops, and a daily med would then push this back forever. */ fun armSafetyNet(context: Context) { try { diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt index 3278db6..b490a2b 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BootReceiver.kt @@ -36,6 +36,7 @@ class BootReceiver : BroadcastReceiver() { // Exported, so any app can send this anything. Keep in step with the manifest. else -> return } + BackgroundSync.armSafetyNet(context) // first: nothing below may fail before this BackgroundSync.request(context, goAsync()) }