diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0d9f1c1..7714191 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json
index ce5cf6c..c3371a8 100644
--- a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json
+++ b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json
@@ -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')"
]
}
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3bd1670..d974fda 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,10 @@
medication reminders are alarm-clock-like core functionality, and this
variant needs no runtime grant. Still gated on canScheduleExactAlarms(). -->
+
+
@@ -39,6 +43,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt
index fa0645e..cffb0ff 100644
--- a/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt
+++ b/app/src/main/java/no/naiv/meddetsamme/MainActivity.kt
@@ -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)")
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt b/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt
index a2b0b4c..26c8a77 100644
--- a/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt
+++ b/app/src/main/java/no/naiv/meddetsamme/MedDetSammeApp.kt
@@ -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() }
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt
new file mode 100644
index 0000000..32bbfdf
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt
@@ -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(),
+ )
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/BatteryOptimization.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/BatteryOptimization.kt
new file mode 100644
index 0000000..577b07d
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/BatteryOptimization.kt
@@ -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}"),
+ )
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/BootReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/BootReceiver.kt
new file mode 100644
index 0000000..dfc4492
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/BootReceiver.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt
new file mode 100644
index 0000000..96dd8ac
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt
@@ -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))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt
new file mode 100644
index 0000000..91b6d76
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt
new file mode 100644
index 0000000..b003dff
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt
@@ -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()
+
+ 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),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
index 30c33b9..d28eabe 100644
--- a/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
+++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
@@ -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,
)
diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
index 33522b0..b3adb77 100644
--- a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
+++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
@@ -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)
}
diff --git a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt
new file mode 100644
index 0000000..2e02d76
--- /dev/null
+++ b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt
@@ -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): 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()
+}
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..a8be6a8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bf22e0f..ed94c2c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }