From 17667bbd42e2b6666820fb4dad345bcf5b276e7d Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:20:30 +0100 Subject: [PATCH 1/3] Alarm screen: drop doses answered elsewhere, close when none are left After its doses were marked Taken on Today, the alarm screen stayed open behind, and Snooze there re-armed alarms for doses already taken. cancelFallback, where every answered-elsewhere path ends, now tells the open alarm screen too. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 +++++ .../dev/susiee/dosekeeper/AlarmActivity.kt | 63 ++++++++++++++++--- .../dev/susiee/dosekeeper/AlarmService.kt | 6 +- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4d1fc65..090419f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,20 @@ feature. Do not trade it away for elegance or convenience. ## Current state (2026-11-04) +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 +~10 min later). Every "answered elsewhere" path (Today, the missed-dose notification's +Taken/Skip, restore, deleting a med) ends in `AlarmScheduler.cancel` -> +`AlarmService.cancelFallback`, which now also calls `AlarmActivity.dropDoses`. The open +screen (`AlarmActivity.shown`, at most one: `singleInstance`) takes them off its list, so +Taken/Snooze there only touch doses still listed. Build-checked only. **Manual check still +to do** (emulator or phone): two meds at the same minute, wait for the alarm screen listing +both, press Home, mark one Taken on Today: the screen lists only the other. Mark that one +too: the screen is gone (not in `adb shell dumpsys activity activities | grep AlarmActivity`), +and nothing rings 10 minutes later (`adb shell dumpsys alarm | grep dev.susiee.dosekeeper` +shows no alarm for them). + 2026-11-07 (review of everything since `dev-1.1.7`): the Android 12 fallback notification (`AlarmService.notifyInstead`) now lists **every** dose due at once. It used one shared id, so a second dose replaced the first, whose alarm had already fired and been forgotten: that diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt index 0bbcd28..b873a6c 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -22,11 +22,15 @@ class AlarmActivity : Activity() { /** Set in onCreate from SnoozePrefs; the label and the snooze use the same value. */ private var snoozeMinutes = 10 + /** The dose name's size from the layout, read once (showDoses shrinks it for several). */ + private var nameTextPx = 0f + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) showOverLockscreen() doses = AlarmService.dosesFrom(intent.extras) + shown = this setContentView(R.layout.activity_alarm) @@ -53,10 +57,27 @@ class AlarmActivity : Activity() { } } + showDoses() + + findViewById(R.id.alarm_taken).setOnClickListener { + finishWith(PendingAction.TAKEN) + } + findViewById(R.id.alarm_snooze).apply { + // Read here, once, so the label and the actual snooze length can't disagree. + snoozeMinutes = SnoozePrefs.snoozeMinutes(this@AlarmActivity) + text = "SNOOZE $snoozeMinutes MIN" + setOnClickListener { finishWith(PendingAction.SNOOZE) } + } + } + + /** Shows [doses]. Again after drop(): two to one goes back to the layout's text size. */ + private fun showDoses() { val one = doses.singleOrNull() findViewById(R.id.alarm_med_name).apply { + if (nameTextPx == 0f) nameTextPx = textSize if (one != null) { text = one.name + setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, nameTextPx) } else { // several due at once: one line each, smaller so they fit text = doses.joinToString("\n") { it.label } textSize = 26f @@ -66,16 +87,26 @@ class AlarmActivity : Activity() { text = one?.dosage.orEmpty() visibility = if (text.isBlank()) android.view.View.GONE else android.view.View.VISIBLE } + } - findViewById(R.id.alarm_taken).setOnClickListener { - finishWith(PendingAction.TAKEN) - } - findViewById(R.id.alarm_snooze).apply { - // Read here, once, so the label and the actual snooze length can't disagree. - snoozeMinutes = SnoozePrefs.snoozeMinutes(this@AlarmActivity) - text = "SNOOZE $snoozeMinutes MIN" - setOnClickListener { finishWith(PendingAction.SNOOZE) } - } + /** + * [doseIds] were answered elsewhere (Today, the missed-dose notification): take them + * off this screen, and close it once none are left. Left listed, Snooze here re-armed + * alarms for doses already taken, and they rang again minutes later. + */ + private fun drop(doseIds: LongArray) { + val left = doses.filter { it.id !in doseIds } + if (left.size == doses.size) return + doses = left + if (left.isEmpty()) { finish(); return } + // So a recreated screen (e.g. after rotating) doesn't bring the answered doses back. + intent.putExtras(AlarmService.dosesBundle(left)) + showDoses() + } + + override fun onDestroy() { + if (shown === this) shown = null + super.onDestroy() } private fun showOverLockscreen() { @@ -135,4 +166,18 @@ class AlarmActivity : Activity() { } enum class PendingAction { TAKEN, SNOOZE } + + companion object { + /** + * The alarm screen that's open, if any (singleInstance: at most one). Main thread + * only, like everything that answers a dose. Cleared in onDestroy, so it never + * holds a closed screen. + */ + private var shown: AlarmActivity? = null + + /** Called from AlarmService.cancelFallback: every "answered elsewhere" path ends there. */ + fun dropDoses(doseIds: LongArray) { + shown?.drop(doseIds) + } + } } diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt index a74210c..66c6180 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -264,7 +264,7 @@ class AlarmService : Service() { } /** The doses as extras: the alarm screen's intent, and the fallback notification. */ - private fun dosesBundle(doses: List) = Bundle().apply { + fun dosesBundle(doses: List) = Bundle().apply { putLongArray(EXTRA_DOSE_IDS, doses.map { it.id }.toLongArray()) putStringArray(EXTRA_MED_NAMES, doses.map { it.name }.toTypedArray()) putStringArray(EXTRA_DOSAGES, doses.map { it.dosage }.toTypedArray()) @@ -338,7 +338,7 @@ class AlarmService : Service() { ?.let { dosesFrom(it.notification.extras) }.orEmpty().toMutableList() .also { fallback = it } - /** The dose was dealt with elsewhere (Today screen): stop it ringing, service or fallback. */ + /** The dose was dealt with elsewhere (Today, missed-dose notification): stop it ringing everywhere. */ fun cancelFallback(context: Context, doseId: Long) { // Marked taken on Today while the service rings for it: without this it kept ringing. if (doseId in ringing) { @@ -348,6 +348,8 @@ class AlarmService : Service() { }) } dropFallback(context, longArrayOf(doseId)) + // And off an alarm screen that's still open for it, or Snooze there rings it again. + AlarmActivity.dropDoses(longArrayOf(doseId)) } /** From 9879a6b5230e66c2bcbf88e053f2cb82d231676c Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:29:24 +0100 Subject: [PATCH 2/3] Alarm screen: remember answered doses across the app being killed Android rebuilds an alarm screen it killed in the background from its first intent, which still listed the answered doses, so Snooze could re-arm them. Answered ids are saved; a dose that fires or rings again is cleared, so a ringing dose is never hidden from the screen that stops it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 ++- .../dev/susiee/dosekeeper/AlarmActivity.kt | 40 +++++++++++++++++-- .../dev/susiee/dosekeeper/AlarmReceiver.kt | 1 + .../dev/susiee/dosekeeper/AlarmService.kt | 5 ++- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68a2be3..5f6bd53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,11 @@ screen stayed open behind, and Snooze there re-armed alarms for taken doses (the Taken/Skip, restore, deleting a med) ends in `AlarmScheduler.cancel` -> `AlarmService.cancelFallback`, which now also calls `AlarmActivity.dropDoses`. The open screen (`AlarmActivity.shown`, at most one: `singleInstance`) takes them off its list, so -Taken/Snooze there only touch doses still listed. Build-checked only. **Manual check still +Taken/Snooze there only touch doses still listed. The answered ids are also saved +(prefs file `dosekeeper_answered_doses`, kept 2 days), because Android rebuilds a screen it +killed in the background from its first intent; `AlarmReceiver` and `AlarmService.startRinging` +clear an id again whenever it fires or rings, so a ringing dose can never be hidden from the +screen that stops it. Build-checked only. **Manual check still to do** (emulator or phone): two meds at the same minute, wait for the alarm screen listing both, press Home, mark one Taken on Today: the screen lists only the other. Mark that one too: the screen is gone (not in `adb shell dumpsys activity activities | grep AlarmActivity`), diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt index b873a6c..6b8142a 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -29,8 +29,11 @@ class AlarmActivity : Activity() { super.onCreate(savedInstanceState) showOverLockscreen() - doses = AlarmService.dosesFrom(intent.extras) + // Not the doses answered since: Android rebuilds a screen killed in the background + // (process death) from its first intent, which still lists them. + doses = AlarmService.dosesFrom(intent.extras).filterNot { isAnswered(this, it.id) } shown = this + if (doses.isEmpty()) { finish(); return } setContentView(R.layout.activity_alarm) @@ -132,6 +135,8 @@ class AlarmActivity : Activity() { * Flutter engine may not be running at all when the alarm fires. */ private fun finishWith(action: PendingAction) { + // Last check: Snooze must never re-arm a dose that was answered elsewhere. + doses = doses.filterNot { isAnswered(this, it.id) } val now = System.currentTimeMillis() if (action == PendingAction.SNOOZE) { val snoozeMs = snoozeMinutes * 60 * 1000L @@ -175,9 +180,38 @@ class AlarmActivity : Activity() { */ private var shown: AlarmActivity? = null - /** Called from AlarmService.cancelFallback: every "answered elsewhere" path ends there. */ - fun dropDoses(doseIds: LongArray) { + /** + * Called from AlarmService.cancelFallback: every "answered elsewhere" path ends there. + * Also saved, for a screen Android rebuilds after killing the app in the background. + */ + fun dropDoses(context: Context, doseIds: LongArray) { + val now = System.currentTimeMillis() + val edit = answered(context).edit() + // ponytail: pruned by age, so the file stays small; a screen left in the background + // longer than this could offer Snooze for an answered dose again (an extra ring only). + answered(context).all.forEach { (k, v) -> + if ((v as? Long ?: 0L) < now - ANSWERED_KEEP_MS) edit.remove(k) + } + doseIds.forEach { edit.putLong(it.toString(), now) } + edit.apply() shown?.drop(doseIds) } + + /** + * The dose's alarm fired or is ringing (re-armed, a new dose reusing the id after a + * restore, a ring Android restarted): it's due, so no screen may hide it. + */ + fun dueAgain(context: Context, doseId: Long) { + answered(context).edit().remove(doseId.toString()).apply() + } + + private fun isAnswered(context: Context, doseId: Long) = + answered(context).contains(doseId.toString()) + + /** Dose id -> when it was answered elsewhere. Its own file: Dart drains BootReceiver.PREFS. */ + private fun answered(context: Context) = + context.getSharedPreferences("dosekeeper_answered_doses", Context.MODE_PRIVATE) + + private const val ANSWERED_KEEP_MS = 2 * 24 * 60 * 60 * 1000L } } 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 b27541b..645bdd4 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmReceiver.kt @@ -28,6 +28,7 @@ class AlarmReceiver : BroadcastReceiver() { .putLong("$KEY_FIRED_PREFIX$doseId", System.currentTimeMillis()) .apply() AlarmScheduler.forget(context, doseId) // fired: nothing left to re-arm after a reboot + AlarmActivity.dueAgain(context, doseId) // due now: an alarm screen must list it val serviceIntent = Intent(context, AlarmService::class.java).apply { action = AlarmService.ACTION_RING diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt index 66c6180..a4bcba0 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -65,6 +65,9 @@ class AlarmService : Service() { // alarm screen shows both: it used to show only the latest, and the other dose // never got a screen of its own. Without releasing the previous ring's // player/vibrator/wakelock first, the old MediaPlayer would keep looping, orphaned. + // Ringing means due, whatever was answered before (e.g. a ring Android brings back + // after killing the app): a screen that hid it would leave this ring unstoppable. + AlarmActivity.dueAgain(this, doseId) ringing[doseId] = Due(doseId, medName, dosage) releaseRingingResources() acquireWakeLock() @@ -349,7 +352,7 @@ class AlarmService : Service() { } dropFallback(context, longArrayOf(doseId)) // And off an alarm screen that's still open for it, or Snooze there rings it again. - AlarmActivity.dropDoses(longArrayOf(doseId)) + AlarmActivity.dropDoses(context, longArrayOf(doseId)) } /** From 5ef73ebbf2348c1cc908cf5edbf070afdd45adc3 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:32:19 +0100 Subject: [PATCH 3/3] Alarm screen: a rebuilt screen older than the answered list closes The answered ids are pruned after 2 days. A screen rebuilt after that can't tell which of its doses were answered, so it closes instead of letting Snooze re-arm one. Its first-opened time rides in saved state, which outlives the process. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- .../dev/susiee/dosekeeper/AlarmActivity.kt | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f6bd53..97a5efe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ Taken/Skip, restore, deleting a med) ends in `AlarmScheduler.cancel` -> `AlarmService.cancelFallback`, which now also calls `AlarmActivity.dropDoses`. The open screen (`AlarmActivity.shown`, at most one: `singleInstance`) takes them off its list, so Taken/Snooze there only touch doses still listed. The answered ids are also saved -(prefs file `dosekeeper_answered_doses`, kept 2 days), because Android rebuilds a screen it +(prefs file `dosekeeper_answered_doses`, kept 2 days; a rebuilt screen older than that closes +itself), because Android rebuilds a screen it killed in the background from its first intent; `AlarmReceiver` and `AlarmService.startRinging` clear an id again whenever it fires or rings, so a ringing dose can never be hidden from the screen that stops it. Build-checked only. **Manual check still diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt index 6b8142a..c5c90c7 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -22,6 +22,13 @@ class AlarmActivity : Activity() { /** Set in onCreate from SnoozePrefs; the label and the snooze use the same value. */ private var snoozeMinutes = 10 + private var openedAt = 0L + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putLong(KEY_OPENED_AT, openedAt) + } + /** The dose name's size from the layout, read once (showDoses shrinks it for several). */ private var nameTextPx = 0f @@ -33,7 +40,12 @@ class AlarmActivity : Activity() { // (process death) from its first intent, which still lists them. doses = AlarmService.dosesFrom(intent.extras).filterNot { isAnswered(this, it.id) } shown = this - if (doses.isEmpty()) { finish(); return } + // Kept across rebuilds (saved state outlives the process): when this screen first opened. + openedAt = savedInstanceState?.getLong(KEY_OPENED_AT) ?: System.currentTimeMillis() + // Older than the answered list remembers: it can't tell which doses were answered, + // so close rather than let Snooze re-arm one. Its doses are long missed by then. + val tooOld = openedAt < System.currentTimeMillis() - ANSWERED_KEEP_MS + if (doses.isEmpty() || tooOld) { finish(); return } setContentView(R.layout.activity_alarm) @@ -187,8 +199,8 @@ class AlarmActivity : Activity() { fun dropDoses(context: Context, doseIds: LongArray) { val now = System.currentTimeMillis() val edit = answered(context).edit() - // ponytail: pruned by age, so the file stays small; a screen left in the background - // longer than this could offer Snooze for an answered dose again (an extra ring only). + // Pruned by age, so the file stays small. A screen older than this closes itself + // when rebuilt (onCreate), so a pruned id can't come back through one. answered(context).all.forEach { (k, v) -> if ((v as? Long ?: 0L) < now - ANSWERED_KEEP_MS) edit.remove(k) } @@ -212,6 +224,7 @@ class AlarmActivity : Activity() { private fun answered(context: Context) = context.getSharedPreferences("dosekeeper_answered_doses", Context.MODE_PRIVATE) + private const val KEY_OPENED_AT = "opened_at" private const val ANSWERED_KEEP_MS = 2 * 24 * 60 * 60 * 1000L } }