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

@ -44,6 +44,7 @@ android {
}
dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "d4412a7aa766343df6b08ea6aa76a83a",
"identityHash": "d2b4dde9106e7b515d31f7dd8a752668",
"entities": [
{
"tableName": "medication",
@ -175,7 +175,7 @@
},
{
"tableName": "dose_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, `nagCount` INTEGER NOT NULL, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
@ -216,6 +216,12 @@
"fieldPath": "actionedAtMillis",
"columnName": "actionedAtMillis",
"affinity": "INTEGER"
},
{
"fieldPath": "nagCount",
"columnName": "nagCount",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@ -291,7 +297,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4412a7aa766343df6b08ea6aa76a83a')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd2b4dde9106e7b515d31f7dd8a752668')"
]
}
}

View file

@ -8,6 +8,10 @@
medication reminders are alarm-clock-like core functionality, and this
variant needs no runtime grant. Still gated on canScheduleExactAlarms(). -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- USE_EXACT_ALARM exists only on API 33+; this covers exactness on 3132. -->
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AlarmManager state is wiped on reboot; we re-arm everything from the DB. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@ -39,6 +43,21 @@
</intent-filter>
</activity>
<!-- Targets of our own PendingIntents only — never exported. -->
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
<receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" />
<!-- AlarmManager state dies with the OS process table; rebuild every alarm
from the DB on boot and after app update. Both actions are protected
broadcasts (system-only senders), so exported=true is safe. -->
<receiver android:name=".alarm.BootReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -1,36 +1,89 @@
package no.naiv.meddetsamme
import android.content.pm.ApplicationInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDateTime
import java.time.ZoneId
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val debuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
setContent {
MaterialTheme {
AppRoot()
AppRoot(showDebugSeed = debuggable, onSeed = ::seedTestDose)
}
}
}
/**
* Debug-only: insert a med with a dose due in 2 minutes and arm it, so the
* fire escalate Taken/Snooze path can be verified on an emulator before
* the real UI exists (milestone 9 replaces this screen entirely).
*/
private fun seedTestDose() {
val app = application as MedDetSammeApp
app.appScope.launch {
val db = MedDatabase.get(this@MainActivity)
val due = LocalDateTime.now().plusMinutes(2)
val medId = db.medicationDao().insert(
Medication(
name = "Testmedisin",
strength = "500 mg",
unit = "tablett",
form = MedForm.TABLET,
inventoryUnits = 10.0,
),
)
val doseTimeId = db.doseTimeDao().insert(
DoseTime(
medId = medId,
minuteOfDay = due.hour * 60 + due.minute,
amount = 1.0,
),
)
AlarmScheduler(this@MainActivity).armDoseTime(doseTimeId)
}
}
}
@Composable
private fun AppRoot() {
private fun AppRoot(showDebugSeed: Boolean, onSeed: () -> Unit) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Text(
text = "Med det samme",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(innerPadding),
)
Column(
modifier = Modifier.padding(innerPadding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Med det samme",
style = MaterialTheme.typography.headlineMedium,
)
if (showDebugSeed) {
Button(onClick = onSeed) {
Text("Seed testdose (+2 min)")
}
}
}
}
}

View file

@ -1,10 +1,27 @@
package no.naiv.meddetsamme
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.notify.Notifications
/**
* Application entry point. Later milestones hang app-wide singletons off this
* (DB, notification channels, alarm re-arm) no DI framework at this size,
* manual wiring via a small service locator.
* App-wide wiring no DI framework at this size (brief). Singletons live in
* MedDatabase.get() and cheap stateless wrappers like AlarmScheduler.
*/
class MedDetSammeApp : Application()
class MedDetSammeApp : Application() {
/** Outlives any activity; for fire-and-forget app-level work. */
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
Notifications.createChannels(this)
// Defensive re-arm on every app start: free, idempotent (alarms replace,
// never stack), and catches anything boot/update broadcasts missed.
appScope.launch { AlarmScheduler(this@MedDetSammeApp).armAll() }
}
}

View file

@ -0,0 +1,140 @@
package no.naiv.meddetsamme.alarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleEngine
/**
* The only writer of AlarmManager state. Invariant: at most ONE pending alarm
* per dose-time (its next occurrence) plus at most one escalation alarm per
* unresolved dose-log. Everything is recomputed from the DB via the pure
* engine reboot, update, Taken and Snooze all just call back in here, so
* alarm state can never drift from schedule state.
*/
class AlarmScheduler(private val context: Context) {
companion object {
/** Escalation contract (CLAUDE.md): re-nag every 10 min, ~6 nags / ~1 h. */
const val NAG_INTERVAL_MINUTES = 10L
const val NAG_CAP = 6
const val SNOOZE_MINUTES = 15L
/** Daily supply/renewal check; lateness is harmless so it's inexact. */
val SUPPLY_CHECK_TIME: LocalTime = LocalTime.of(10, 0)
private const val SUPPLY_REQUEST_CODE = 9_001
}
private val alarmManager = context.getSystemService(AlarmManager::class.java)
private val zone: ZoneId get() = ZoneId.systemDefault()
/**
* Exact when permitted, inexact otherwise a slightly late reminder still
* beats a crash, and canScheduleExactAlarms() can be revoked at runtime.
* On API 33+ USE_EXACT_ALARM makes this effectively always true.
*/
private fun setAlarm(triggerAtMillis: Long, operation: PendingIntent) {
val canExact = Build.VERSION.SDK_INT < 31 || alarmManager.canScheduleExactAlarms()
if (canExact) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, operation)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, operation)
}
}
// ---- dose-time occurrence alarms --------------------------------------
private fun doseIntent(doseTimeId: Long, scheduledAtMillis: Long): PendingIntent =
PendingIntent.getBroadcast(
context,
doseTimeId.mod(Int.MAX_VALUE.toLong()).toInt(),
Intent(context, DoseAlarmReceiver::class.java)
.putExtra(DoseAlarmReceiver.EXTRA_DOSE_TIME_ID, doseTimeId)
.putExtra(DoseAlarmReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Arm (or re-arm) the single alarm for one dose-time's next occurrence. */
suspend fun armDoseTime(doseTimeId: Long, after: LocalDateTime = LocalDateTime.now()) {
val db = MedDatabase.get(context)
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
val med = db.medicationDao().getById(doseTime.medId)
if (med == null || !med.active) {
cancelDoseTime(doseTimeId)
return
}
val next = ScheduleEngine.nextOccurrence(doseTime, after)
if (next == null) {
cancelDoseTime(doseTimeId)
return
}
val millis = next.atZone(zone).toInstant().toEpochMilli()
// Same requestCode per dose-time + FLAG_UPDATE_CURRENT = the invariant:
// re-arming replaces, it never stacks.
setAlarm(millis, doseIntent(doseTimeId, millis))
}
fun cancelDoseTime(doseTimeId: Long) {
alarmManager.cancel(doseIntent(doseTimeId, 0))
}
/** Full rebuild from DB: boot, app update, schedule edits, app start. */
suspend fun armAll() {
val db = MedDatabase.get(context)
db.doseTimeDao().getAllForActiveMeds().forEach { armDoseTime(it.id) }
armSupplyCheck()
// Resume nagging for doses that were unresolved when the device died.
db.doseLogDao().getUnresolved().forEach { log ->
if (log.nagCount < NAG_CAP) armEscalation(log.id, delayMinutes = 1)
}
}
// ---- escalation alarms -------------------------------------------------
private fun escalationIntent(logId: Long): PendingIntent =
PendingIntent.getBroadcast(
context,
// Offset keeps escalation requestCodes from colliding with dose-time codes.
(logId.mod((Int.MAX_VALUE / 2).toLong())).toInt() + Int.MAX_VALUE / 2,
Intent(context, DoseAlarmReceiver::class.java)
.putExtra(DoseAlarmReceiver.EXTRA_LOG_ID, logId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** One escalation alarm per unresolved log; re-arming replaces. */
fun armEscalation(logId: Long, delayMinutes: Long = NAG_INTERVAL_MINUTES) {
val triggerAt = System.currentTimeMillis() + delayMinutes * 60_000
setAlarm(triggerAt, escalationIntent(logId))
}
fun cancelEscalation(logId: Long) {
alarmManager.cancel(escalationIntent(logId))
}
// ---- daily supply / renewal check --------------------------------------
private fun supplyIntent(): PendingIntent =
PendingIntent.getBroadcast(
context,
SUPPLY_REQUEST_CODE,
Intent(context, SupplyCheckReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Inexact daily check at ~10:00; the receiver re-arms the next one. */
fun armSupplyCheck() {
var next = LocalDateTime.of(java.time.LocalDate.now(), SUPPLY_CHECK_TIME)
if (!next.isAfter(LocalDateTime.now())) next = next.plusDays(1)
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
next.atZone(zone).toInstant().toEpochMilli(),
supplyIntent(),
)
}
}

View file

@ -0,0 +1,29 @@
package no.naiv.meddetsamme.alarm
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
/**
* Battery-optimisation ("Doze whitelist") exemption brief decision #4: the
* single biggest cause of dropped reminders on aggressive OEMs. The UI shows a
* one-time prompt built on these helpers; we never nag about it repeatedly.
*/
object BatteryOptimization {
fun isExempt(context: Context): Boolean =
context.getSystemService(PowerManager::class.java)
.isIgnoringBatteryOptimizations(context.packageName)
/**
* Direct request dialog. Allowed by policy for alarm/reminder apps where
* timing is core functionality which is this app's entire reason to exist.
*/
fun requestExemptionIntent(context: Context): Intent =
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${context.packageName}"),
)
}

View file

@ -0,0 +1,31 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* AlarmManager forgets everything on reboot, and an app update kills pending
* intents pointing at old code. Both events land here; the fix is the same:
* rebuild all alarms from the DB (brief decision #3).
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
else -> return
}
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
AlarmScheduler(context).armAll()
} finally {
pending.finish()
}
}
}
}

View file

@ -0,0 +1,72 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
/**
* Handles the notification actions (and later, the same actions from the UI).
*
* Contract (CLAUDE.md, load-bearing):
* - Taken TAKEN, decrement inventory by the *logged* amount, stop escalating.
* - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
* - Skip SKIPPED, stop escalating, no inventory change.
*/
class DoseActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
const val EXTRA_LOG_ID = "logId"
}
override fun onReceive(context: Context, intent: Intent) {
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
if (logId < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
handle(context, action, logId)
} finally {
pending.finish()
}
}
}
private suspend fun handle(context: Context, action: String, logId: Long) {
val db = MedDatabase.get(context)
val scheduler = AlarmScheduler(context)
val log = db.doseLogDao().getById(logId) ?: return
val now = System.currentTimeMillis()
when (action) {
ACTION_TAKEN -> {
// Idempotence: double-tap on a laggy notification must not
// decrement stock twice.
if (log.status == DoseStatus.TAKEN) return
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, now)
db.medicationDao().decrementInventory(log.medId, log.amount)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
ACTION_SNOOZE -> {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, now)
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
ACTION_SKIP -> {
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, now)
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
}
}
}

View file

@ -0,0 +1,109 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
/**
* Fires for both alarm kinds:
* - occurrence alarm (EXTRA_DOSE_TIME_ID + EXTRA_SCHEDULED_AT): creates the
* PENDING log, notifies, starts escalation, and re-arms the next occurrence.
* - escalation alarm (EXTRA_LOG_ID): re-nags an unresolved log until the cap.
*
* Both paths are idempotent: a duplicate delivery finds existing state and
* does nothing harmful important because AlarmManager redelivery and boot
* re-arming can overlap.
*/
class DoseAlarmReceiver : BroadcastReceiver() {
companion object {
const val EXTRA_DOSE_TIME_ID = "doseTimeId"
const val EXTRA_SCHEDULED_AT = "scheduledAt"
const val EXTRA_LOG_ID = "logId"
}
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync() // keep the process alive for the DB round-trip
CoroutineScope(Dispatchers.IO).launch {
try {
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
if (logId >= 0) {
escalate(context, logId)
} else {
fireOccurrence(
context,
intent.getLongExtra(EXTRA_DOSE_TIME_ID, -1),
intent.getLongExtra(EXTRA_SCHEDULED_AT, -1),
)
}
} finally {
pending.finish()
}
}
}
private suspend fun fireOccurrence(context: Context, doseTimeId: Long, scheduledAt: Long) {
if (doseTimeId < 0 || scheduledAt < 0) return
val db = MedDatabase.get(context)
val scheduler = AlarmScheduler(context)
// ALWAYS arm the next occurrence first — even if this one turns out to be
// stale, the chain must never break.
scheduler.armDoseTime(doseTimeId)
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
val med = db.medicationDao().getById(doseTime.medId) ?: return
if (!med.active) return
val existing = db.doseLogDao().getForOccurrence(doseTimeId, scheduledAt)
val log = when {
existing == null -> {
val id = db.doseLogDao().insert(
DoseLog(
medId = med.id,
doseTimeId = doseTimeId,
scheduledAtMillis = scheduledAt,
amount = doseTime.amount,
),
)
db.doseLogDao().getById(id)!!
}
// Already resolved (e.g. taken early from the UI) — no notification.
existing.status == DoseStatus.TAKEN || existing.status == DoseStatus.SKIPPED -> return
else -> existing
}
Notifications.post(
context,
Notifications.doseNotificationId(log.id),
Notifications.buildDoseNotification(context, med, log),
)
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
}
private suspend fun escalate(context: Context, logId: Long) {
val db = MedDatabase.get(context)
val log = db.doseLogDao().getById(logId) ?: return
// Only unresolved doses nag. TAKEN/SKIPPED here means the cancel raced us.
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
if (log.nagCount >= AlarmScheduler.NAG_CAP) return
val med = db.medicationDao().getById(log.medId) ?: return
val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount)
Notifications.post(
context,
Notifications.doseNotificationId(logId),
Notifications.buildDoseNotification(context, med, nagged),
)
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
}
}

View file

@ -0,0 +1,59 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.notify.Notifications
/**
* Daily derived-state check: low stock (inventory ÷ scheduled rate) and
* prescription renewal (rx expiry, independent of stock decision #9).
* One digest notification, not one per med.
*/
class SupplyCheckReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
check(context)
} finally {
AlarmScheduler(context).armSupplyCheck() // chain the next daily check
pending.finish()
}
}
}
private suspend fun check(context: Context) {
val db = MedDatabase.get(context)
val today = LocalDate.now()
val lines = mutableListOf<String>()
for (med in db.medicationDao().getActive()) {
val doseTimes = db.doseTimeDao().getForMed(med.id)
val rate = ScheduleEngine.dailyConsumption(doseTimes)
if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) {
val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)
lines += "${med.name}: lager for ${days?.toInt() ?: 0} dager"
}
if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) {
val expired = med.rxExpiryEpochDay!! < today.toEpochDay()
lines += if (expired) "${med.name}: resept utløpt" else "${med.name}: resept må fornyes"
}
}
if (lines.isNotEmpty()) {
Notifications.post(
context,
Notifications.SUPPLY_NOTIFICATION_ID,
Notifications.buildSupplyNotification(context, lines),
)
}
}
}

View file

@ -54,4 +54,9 @@ data class DoseLog(
val status: DoseStatus = DoseStatus.PENDING,
/** When the user last acted on it (Taken/Skipped/Snoozed), null while untouched. */
val actionedAtMillis: Long? = null,
/**
* Escalation nags already sent for this occurrence. Lives in the DB, not in
* alarm extras, so the ~6-nag cap survives a reboot mid-escalation.
*/
val nagCount: Int = 0,
)

View file

@ -43,4 +43,7 @@ interface DoseLogDao {
@Query("UPDATE dose_log SET status = :status, actionedAtMillis = :actionedAtMillis WHERE id = :id")
suspend fun setStatus(id: Long, status: DoseStatus, actionedAtMillis: Long?)
@Query("UPDATE dose_log SET nagCount = :nagCount WHERE id = :id")
suspend fun setNagCount(id: Long, nagCount: Int)
}

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()
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar icon: must be flat white-on-transparent (the system tints it). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:rotation="45"
android:pivotX="12"
android:pivotY="12">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,4 h6 a4,4 0 0 1 4,4 v0 h-14 v0 a4,4 0 0 1 4,-4 z" />
<path
android:fillColor="#80FFFFFF"
android:pathData="M5,8 h14 v8 a4,4 0 0 1 -4,4 h-6 a4,4 0 0 1 -4,-4 z" />
</group>
</vector>

View file

@ -13,6 +13,7 @@ lifecycle = "2.10.0"
room = "2.8.4" # Room 3 (androidx.room3) is still alpha — not for a medication app
work = "2.11.2"
okhttp = "5.4.0"
coroutines = "1.11.0"
serializationJson = "1.11.0"
junit = "4.13.2"
@ -30,6 +31,7 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
junit = { group = "junit", name = "junit", version.ref = "junit" }