diff --git a/AGENTS.md b/AGENTS.md index c8825ce..97a5efe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,25 @@ 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. The answered ids are also saved +(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 +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-08: **the Android 12 fallback (PR #24 + #37) passed on an Android 12 emulator.** Two doses at the same minute: one fallback notification listing both, its alarm screen listing both and ringing (an alarm-stream player in `dumpsys audio`), one marked Taken on @@ -26,9 +45,9 @@ Today left the other listed and ringing, and answering both stopped everything. case passed too: dose C's alarm screen open, dose D due minutes later (the service then starts directly, since the app is in the foreground), Taken on C left D listed and ringing. No app errors in logcat. Not yet seen on a real Android 12 phone. -Known, not fixed: after both doses were answered on **Today**, C's alarm screen stayed open -behind (stale). Snooze there would re-arm alarms for doses already taken. Untested. -How to repeat it (about 30 minutes): +Found here: after both doses were answered on **Today**, C's alarm screen stayed open +behind (stale), and Snooze there would re-arm alarms for taken doses. Fixed in PR #39 (entry above). +How to repeat the test (about 30 minutes): - AVD `dosekeeper-a12` (`system-images;android-31;google_apis;x86_64`, pixel_5). Open it from T3's Device panel. Its serial depends on launch order (it was `emulator-5556` here, with `dosekeeper-test` on 5554), so find it with `adb devices -l` (or `adb -s emu avd 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..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,11 +22,30 @@ 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 + override fun onCreate(savedInstanceState: Bundle?) { 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 + // 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) @@ -53,10 +72,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 +102,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() { @@ -101,6 +147,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 @@ -135,4 +183,48 @@ 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. + * 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() + // 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) + } + 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 KEY_OPENED_AT = "opened_at" + 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 a74210c..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() @@ -264,7 +267,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 +341,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 +351,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(context, longArrayOf(doseId)) } /**