From f8bc8a09e8527c01eb561c1fc8f1d81953378c65 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:20:05 +0100 Subject: [PATCH 1/4] Android 12 fallback: doses due together all ring, not just the last On Android 12 without "Alarms & reminders" access, each dose's alarm posts the fallback notification itself. Two doses due at the same time each posted it under the one shared id, so the second replaced the first. The first dose's alarm had already fired and been forgotten, so it never showed or rang. The fallback now keeps the doses it already lists, its alarm screen starts ringing for every one of them, and marking one on Today takes only that one off. Co-Authored-By: Claude Opus 5.5 --- .../dev/susiee/dosekeeper/AlarmActivity.kt | 30 +++++------ .../dev/susiee/dosekeeper/AlarmService.kt | 50 ++++++++++++++----- 2 files changed, 51 insertions(+), 29 deletions(-) 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 4e6c711..8f808f2 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -26,29 +26,25 @@ class AlarmActivity : Activity() { super.onCreate(savedInstanceState) showOverLockscreen() - val ids = intent.getLongArrayExtra(AlarmService.EXTRA_DOSE_IDS) ?: LongArray(0) - val names = intent.getStringArrayExtra(AlarmService.EXTRA_MED_NAMES) - val dosages = intent.getStringArrayExtra(AlarmService.EXTRA_DOSAGES) - doses = ids.mapIndexed { i, id -> - AlarmService.Due(id, names?.getOrNull(i) ?: "Medication", dosages?.getOrNull(i) ?: "") - } + doses = AlarmService.dosesFrom(intent.extras) setContentView(R.layout.activity_alarm) // Opened from AlarmService.notifyInstead: nothing is ringing yet. On screen, the app may start it. if (savedInstanceState == null && intent.getBooleanExtra(AlarmService.EXTRA_RING_ON_OPEN, false)) { runCatching { - androidx.core.content.ContextCompat.startForegroundService( - this, - Intent(this, AlarmService::class.java).apply { - action = AlarmService.ACTION_RING - doses.firstOrNull()?.let { // the fallback is always one dose - putExtra(AlarmScheduler.EXTRA_DOSE_ID, it.id) - putExtra(AlarmScheduler.EXTRA_MED_NAME, it.name) - putExtra(AlarmScheduler.EXTRA_DOSAGE, it.dosage) - } - }, - ) + // Every listed dose: doses due together share the fallback notification. + for (dose in doses) { + androidx.core.content.ContextCompat.startForegroundService( + this, + Intent(this, AlarmService::class.java).apply { + action = AlarmService.ACTION_RING + putExtra(AlarmScheduler.EXTRA_DOSE_ID, dose.id) + putExtra(AlarmScheduler.EXTRA_MED_NAME, dose.name) + putExtra(AlarmScheduler.EXTRA_DOSAGE, dose.dosage) + }, + ) + } }.onFailure { // The insistent notification keeps ringing until Taken/Snooze, so this isn't silent. android.util.Log.e("DoseKeeper/Activity", "couldn't start ringing from the alarm screen", it) 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 7e96033..e44e9cc 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -12,6 +12,7 @@ import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager import android.os.Build +import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper @@ -238,9 +239,7 @@ class AlarmService : Service() { doses.last().id.toInt(), Intent(context, AlarmActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra(EXTRA_DOSE_IDS, doses.map { it.id }.toLongArray()) - putExtra(EXTRA_MED_NAMES, doses.map { it.name }.toTypedArray()) - putExtra(EXTRA_DOSAGES, doses.map { it.dosage }.toTypedArray()) + putExtras(dosesBundle(doses)) putExtra(EXTRA_RING_ON_OPEN, ringOnOpen) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, @@ -261,6 +260,22 @@ class AlarmService : Service() { .build() } + /** The doses as extras: the alarm screen's intent, and the fallback notification. */ + private 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()) + } + + fun dosesFrom(extras: Bundle?): List { + val ids = extras?.getLongArray(EXTRA_DOSE_IDS) ?: return emptyList() + val names = extras.getStringArray(EXTRA_MED_NAMES) + val dosages = extras.getStringArray(EXTRA_DOSAGES) + return ids.mapIndexed { i, id -> + Due(id, names?.getOrNull(i) ?: "Medication", dosages?.getOrNull(i) ?: "") + } + } + /** * For when Android won't start this service: on Android 12 the inexact fallback alarm * (no "Alarms & reminders" access) gives no right to start one from the background. @@ -268,6 +283,14 @@ class AlarmService : Service() { * Until then (unlocked phone: only a heads-up), the notification itself rings, on repeat. */ fun notifyInstead(context: Context, doseId: Long, medName: String, dosage: String) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + // Two doses due together each land here. Keep the ones already listed: posting + // just this one replaced them, and an earlier dose (its alarm already fired and + // forgotten) then never showed or rang. + postFallback(context, fallbackDoses(nm).filter { it.id != doseId } + Due(doseId, medName, dosage)) + } + + private fun postFallback(context: Context, doses: List) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // only Android 12 reaches here nm.createNotificationChannel( @@ -287,13 +310,18 @@ class AlarmService : Service() { }, ) } - val n = buildNotification(context, listOf(Due(doseId, medName, dosage)), ringOnOpen = true, channel = FALLBACK_CHANNEL_ID) - n.extras.putLong(AlarmScheduler.EXTRA_DOSE_ID, doseId) // see cancelFallback + val n = buildNotification(context, doses, ringOnOpen = true, channel = FALLBACK_CHANNEL_ID) + n.extras.putAll(dosesBundle(doses)) // see fallbackDoses n.flags = n.flags or Notification.FLAG_INSISTENT // repeat the sound until it's dealt with // Same id as the service's: when the alarm screen starts the ringing, that replaces this. nm.notify(NOTIFICATION_ID, n) } + /** The doses the fallback notification lists; none if it isn't showing. */ + private fun fallbackDoses(nm: NotificationManager): List = + nm.activeNotifications.firstOrNull { it.id == NOTIFICATION_ID } + ?.let { dosesFrom(it.notification.extras) }.orEmpty() + /** The dose was dealt with elsewhere (Today screen): stop it ringing, service or fallback. */ fun cancelFallback(context: Context, doseId: Long) { // Marked taken on Today while the service rings for it: without this it kept ringing. @@ -304,13 +332,11 @@ class AlarmService : Service() { }) } val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - // One id for every dose, so check it's this dose's before clearing it. - if (nm.activeNotifications.any { - it.id == NOTIFICATION_ID && it.notification.extras.getLong(AlarmScheduler.EXTRA_DOSE_ID, -1L) == doseId - } - ) { - nm.cancel(NOTIFICATION_ID) - } + // One notification for every dose: take this one off it, the rest keep ringing. + val listed = fallbackDoses(nm) + if (listed.none { it.id == doseId }) return + val rest = listed.filter { it.id != doseId } + if (rest.isEmpty()) nm.cancel(NOTIFICATION_ID) else postFallback(context, rest) } /** [doseIds]: the doses the alarm screen dealt with; one that came due since keeps ringing. */ From d73477e1dd1b068ad42c16c2ac71ada175af8a3e Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:21:40 +0100 Subject: [PATCH 2/4] AGENTS.md: note the fallback fix from the post-dev-1.1.7 review Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 23af882..a720714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,13 @@ feature. Do not trade it away for elegance or convenience. ## Current state (2026-11-04) +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 +dose never showed or rang. Its alarm screen now starts ringing for each listed dose, and +`cancelFallback` takes only the handled dose off. Build-checked only, like the rest of the +fallback: no Android 12 device or emulator here. + 2026-11-07: the backup folder pick is **two-phase** (issue #30) -- a picked folder is pending until the "restore or keep?" question is answered, so an unanswered pick can no longer redirect automatic backups. See the Backup & restore entry under "Implemented". From bf1445891d67dff26d5f7a4d4f6ebc164cf711ce Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:30:01 +0100 Subject: [PATCH 3/4] Fallback: keep its dose list in memory, not only in the notification notify() is asynchronous, so a second dose arriving right after the first could read the active notifications before the first was listed and post only itself, dropping the first again. The list now lives in memory and is read from the notification only when the process is new. (CodeRabbit) Co-Authored-By: Claude Opus 5.5 --- .../dev/susiee/dosekeeper/AlarmService.kt | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) 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 e44e9cc..59bb130 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -287,7 +287,11 @@ class AlarmService : Service() { // Two doses due together each land here. Keep the ones already listed: posting // just this one replaced them, and an earlier dose (its alarm already fired and // forgotten) then never showed or rang. - postFallback(context, fallbackDoses(nm).filter { it.id != doseId } + Due(doseId, medName, dosage)) + val doses = fallbackDoses(nm).apply { + removeAll { it.id == doseId } + add(Due(doseId, medName, dosage)) + } + postFallback(context, doses.toList()) } private fun postFallback(context: Context, doses: List) { @@ -317,10 +321,19 @@ class AlarmService : Service() { nm.notify(NOTIFICATION_ID, n) } - /** The doses the fallback notification lists; none if it isn't showing. */ - private fun fallbackDoses(nm: NotificationManager): List = - nm.activeNotifications.firstOrNull { it.id == NOTIFICATION_ID } - ?.let { dosesFrom(it.notification.extras) }.orEmpty() + /** + * The doses the fallback notification lists. Kept here, not only read back from the + * notification: notify() is asynchronous, so a dose posted a moment ago (two doses + * due together) may not be listed there yet. Read from the notification only when + * this process hasn't posted one yet (it may have been restarted since). + * Main thread only: receivers, the alarm screen and the method channel all run there. + */ + private var fallback: MutableList? = null + + private fun fallbackDoses(nm: NotificationManager): MutableList = + fallback ?: nm.activeNotifications.firstOrNull { it.id == NOTIFICATION_ID } + ?.let { dosesFrom(it.notification.extras) }.orEmpty().toMutableList() + .also { fallback = it } /** The dose was dealt with elsewhere (Today screen): stop it ringing, service or fallback. */ fun cancelFallback(context: Context, doseId: Long) { @@ -334,15 +347,16 @@ class AlarmService : Service() { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // One notification for every dose: take this one off it, the rest keep ringing. val listed = fallbackDoses(nm) - if (listed.none { it.id == doseId }) return - val rest = listed.filter { it.id != doseId } - if (rest.isEmpty()) nm.cancel(NOTIFICATION_ID) else postFallback(context, rest) + if (!listed.removeAll { it.id == doseId }) return + if (listed.isEmpty()) nm.cancel(NOTIFICATION_ID) else postFallback(context, listed.toList()) } /** [doseIds]: the doses the alarm screen dealt with; one that came due since keeps ringing. */ fun stop(context: Context, doseIds: LongArray) { // Posted by notifyInstead when the service never started, so stopping it won't clear it. - (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(NOTIFICATION_ID) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + fallbackDoses(nm).removeAll { it.id in doseIds } + nm.cancel(NOTIFICATION_ID) context.startService(Intent(context, AlarmService::class.java).apply { action = ACTION_STOP putExtra(EXTRA_DOSE_IDS, doseIds) From 4ebd2c8b0f4e8277dd6f77c4202ae8529df91914 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:37:48 +0100 Subject: [PATCH 4/4] Fallback: its own notification id, so answering one dose keeps the other With the alarm screen open for one dose, a second dose that fell back was added to the same notification id the ringing service uses. Answering the first cancelled that id (and stopping the service removes it too), so the second dose's notification vanished and it never rang. The fallback now has its own id; the alarm screen and Taken/Snooze take only their doses off it, and the rest keep ringing. (CodeRabbit) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++- .../dev/susiee/dosekeeper/AlarmActivity.kt | 2 ++ .../dev/susiee/dosekeeper/AlarmService.kt | 26 ++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a720714..4d1fc65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,10 @@ feature. Do not trade it away for elegance or convenience. (`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 dose never showed or rang. Its alarm screen now starts ringing for each listed dose, and -`cancelFallback` takes only the handled dose off. Build-checked only, like the rest of the +`cancelFallback`/`stop` take only the handled doses off. The fallback has its own +notification id (4244), separate from the ringing service's (4242): stopping the service +removes whatever sits under its id, which used to take an unanswered fallback dose with it. +The list is kept in memory too, since `notify()` is asynchronous. Build-checked only, like the rest of the fallback: no Android 12 device or emulator here. 2026-11-07: the backup folder pick is **two-phase** (issue #30) -- a picked folder is 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 8f808f2..0bbcd28 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -45,6 +45,8 @@ class AlarmActivity : Activity() { }, ) } + // Ringing in the service now: the fallback's own sound must stop for them. + AlarmService.dropFallback(this, doses.map { it.id }.toLongArray()) }.onFailure { // The insistent notification keeps ringing until Taken/Snooze, so this isn't silent. android.util.Log.e("DoseKeeper/Activity", "couldn't start ringing from the alarm screen", it) 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 59bb130..a74210c 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -205,6 +205,9 @@ class AlarmService : Service() { const val CHANNEL_ID = "dose_alarms" private const val FALLBACK_CHANNEL_ID = "dose_alarms_fallback" const val NOTIFICATION_ID = 4242 + // Its own id, not the service's: stopping the service removes whatever sits under + // NOTIFICATION_ID, which took a still-unanswered fallback dose with it. + private const val FALLBACK_NOTIFICATION_ID = 4244 private const val RING_TIMEOUT_MS = 5 * 60 * 1000L private const val RAMP_START_VOLUME = 0.1f private const val RAMP_STEPS = 30 @@ -317,8 +320,8 @@ class AlarmService : Service() { val n = buildNotification(context, doses, ringOnOpen = true, channel = FALLBACK_CHANNEL_ID) n.extras.putAll(dosesBundle(doses)) // see fallbackDoses n.flags = n.flags or Notification.FLAG_INSISTENT // repeat the sound until it's dealt with - // Same id as the service's: when the alarm screen starts the ringing, that replaces this. - nm.notify(NOTIFICATION_ID, n) + // The alarm screen takes its doses off this once it has started them ringing. + nm.notify(FALLBACK_NOTIFICATION_ID, n) } /** @@ -331,7 +334,7 @@ class AlarmService : Service() { private var fallback: MutableList? = null private fun fallbackDoses(nm: NotificationManager): MutableList = - fallback ?: nm.activeNotifications.firstOrNull { it.id == NOTIFICATION_ID } + fallback ?: nm.activeNotifications.firstOrNull { it.id == FALLBACK_NOTIFICATION_ID } ?.let { dosesFrom(it.notification.extras) }.orEmpty().toMutableList() .also { fallback = it } @@ -344,19 +347,24 @@ class AlarmService : Service() { putExtra(EXTRA_DOSE_IDS, longArrayOf(doseId)) }) } + dropFallback(context, longArrayOf(doseId)) + } + + /** + * Takes [doseIds] off the fallback notification: answered, or now ringing in the + * service. One notification for every dose, so the rest stay on it, still ringing. + */ + fun dropFallback(context: Context, doseIds: LongArray) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - // One notification for every dose: take this one off it, the rest keep ringing. val listed = fallbackDoses(nm) - if (!listed.removeAll { it.id == doseId }) return - if (listed.isEmpty()) nm.cancel(NOTIFICATION_ID) else postFallback(context, listed.toList()) + if (!listed.removeAll { it.id in doseIds }) return + if (listed.isEmpty()) nm.cancel(FALLBACK_NOTIFICATION_ID) else postFallback(context, listed.toList()) } /** [doseIds]: the doses the alarm screen dealt with; one that came due since keeps ringing. */ fun stop(context: Context, doseIds: LongArray) { // Posted by notifyInstead when the service never started, so stopping it won't clear it. - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - fallbackDoses(nm).removeAll { it.id in doseIds } - nm.cancel(NOTIFICATION_ID) + dropFallback(context, doseIds) context.startService(Intent(context, AlarmService::class.java).apply { action = ACTION_STOP putExtra(EXTRA_DOSE_IDS, doseIds)