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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
32 changes: 15 additions & 17 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 63 additions & 15 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -261,13 +263,41 @@ class AlarmService : Service() {
.build()
}

/** The doses as extras: the alarm screen's intent, and the fallback notification. */
private fun dosesBundle(doses: List<Due>) = 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<Due> {
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.
* Shows the alarm screen instead, which starts the ringing once it's open (allowed then).
* 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<Due>) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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(
Expand All @@ -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<Due>? = null

private fun fallbackDoses(nm: NotificationManager): MutableList<Due> =
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.
Expand All @@ -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)
Expand Down
Loading