Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@ 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). 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
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 <ms>` / `cmd alarm set-timezone <zone>`, 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.
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.

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
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@
<!-- Some OEM skins (incl. ColorOS) use non-standard boot broadcasts. -->
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<!-- Wall clock or zone changed: armed alarms now ring at the wrong time. -->
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
</intent-filter>
</receiver>

Expand Down
135 changes: 135 additions & 0 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmChannel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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<Number>("doseId")!!).toLong()
val triggerAt = (call.argument<Number>("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<String>("medName") ?: "Medication",
dosage = call.argument<String>("dosage") ?: "",
),
)
}

"cancelDose" -> {
AlarmScheduler.cancel(context, (call.argument<Number>("doseId")!!).toLong())
result.success(true)
}

/** Outcomes the native alarm screen recorded while Dart wasn't running. */
"readPendingActions" -> {
val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE)
val out = mutableListOf<Map<String, Any>>()
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),
),
)
}
}
}
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<Number>("doseId")!!).toLong()
val at = (call.argument<Number>("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 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.
*/
"drainFiredEvents" -> {
val prefs = context.getSharedPreferences(BootReceiver.PREFS, Context.MODE_PRIVATE)
val out = mutableListOf<Map<String, Any>>()
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<String>("title") ?: "Missed dose",
text = call.argument<String>("text") ?: "",
shownAt = call.argument<Number>("shownAt")?.toLong() ?: 0L,
// Exactly one missed dose: the notification gets Taken/Skip buttons.
doseId = call.argument<Number>("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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
}
}
163 changes: 163 additions & 0 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/BackgroundSync.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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<BroadcastReceiver.PendingResult>()
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
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.
*
* 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 {
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)
}
}
}
Loading
Loading