diff --git a/AGENTS.md b/AGENTS.md index 23af882..4d1fc65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,16 @@ 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`/`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 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". 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..0bbcd28 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,27 @@ 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) + }, + ) + } + // 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 7e96033..a74210c 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 @@ -204,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 @@ -238,9 +242,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 +263,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 +286,18 @@ 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. + 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) { 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 +317,27 @@ 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 alarm screen takes its doses off this once it has started them ringing. + nm.notify(FALLBACK_NOTIFICATION_ID, n) } + /** + * 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 == FALLBACK_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) { // Marked taken on Today while the service rings for it: without this it kept ringing. @@ -303,20 +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 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) - } + val listed = fallbackDoses(nm) + 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. - (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(NOTIFICATION_ID) + dropFallback(context, doseIds) context.startService(Intent(context, AlarmService::class.java).apply { action = ACTION_STOP putExtra(EXTRA_DOSE_IDS, doseIds)