Reminder subsystem: exact alarms, escalation, boot re-arm — verified on emulator

Milestone 4. AlarmScheduler is the only AlarmManager writer; one stable
PendingIntent requestCode per dose-time means re-arming replaces and can
never stack (the no-drift invariant is OS-enforced). DoseAlarmReceiver
handles both occurrence and escalation alarms idempotently and always
re-arms the next occurrence before anything else so the chain can't
break. nagCount lives in dose_log so the ~6-nag cap survives reboot.
SCHEDULE_EXACT_ALARM added maxSdk 32 (USE_EXACT_ALARM is 33+ only).

Emulator-verified (API 35): exact alarm armed (window=0,
policy_permission), fired on time, HIGH notification with Tatt/Utsett,
escalation armed +10 min, next day re-armed, Tatt → TAKEN + inventory
10→9 + escalation cancelled, reboot → BootReceiver re-armed everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 13:48:39 +02:00
commit 46a5d7e98e
16 changed files with 714 additions and 14 deletions

View file

@ -0,0 +1,135 @@
package no.naiv.meddetsamme.notify
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import no.naiv.meddetsamme.MainActivity
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.DoseActionReceiver
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.Medication
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
object Notifications {
const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply"
/** Stable per-occurrence notification id: every re-nag *replaces* the card. */
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
fun createChannels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_DOSES,
"Doser",
// HIGH: heads-up + sound. The entire point of the app.
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Påminnelser om å ta medisin"
setBypassDnd(false)
},
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_SUPPLY,
"Lager og resept",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Lite på lager eller resept som må fornyes"
},
)
}
/**
* The dose reminder card. [nagCount] > 0 marks a re-nag same id, so it
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
* is the escalation).
*/
fun buildDoseNotification(context: Context, med: Medication, log: DoseLog): Notification {
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
val title = "${med.name} ${med.strength}".trim()
val body = buildString {
append("${formatAmount(log.amount)} ${med.unit} kl. ${timeFmt.format(scheduled)}")
if (med.withFood) append(" — tas med mat")
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
}
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
context,
// Distinct requestCode per (log, action); same (log, action) replaces.
(log.id * 10 + action.hashCode().mod(10)).toInt(),
Intent(context, DoseActionReceiver::class.java)
.setAction(action)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
doseNotificationId(log.id),
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_DOSES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(false)
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
.build()
}
fun buildSupplyNotification(context: Context, lines: List<String>): Notification {
val contentIntent = PendingIntent.getActivity(
context,
SUPPLY_NOTIFICATION_ID,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_SUPPLY)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(if (lines.size == 1) lines.first() else "Apotek-ærend (${lines.size})")
.setContentText(lines.joinToString(" · "))
.setStyle(NotificationCompat.InboxStyle().also { s -> lines.forEach(s::addLine) })
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(contentIntent)
.setAutoCancel(true)
.build()
}
/** Post if allowed; silently skipping is fine — channel-level blocks are user intent. */
fun post(context: Context, id: Int, notification: Notification) {
val nm = NotificationManagerCompat.from(context)
if (nm.areNotificationsEnabled()) {
try {
nm.notify(id, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked between check and post; nothing to do.
}
}
}
fun cancel(context: Context, id: Int) = NotificationManagerCompat.from(context).cancel(id)
private fun formatAmount(amount: Double): String =
if (amount == amount.toLong().toDouble()) amount.toLong().toString() else amount.toString()
}