med-det-samme/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt

150 lines
6.2 KiB
Kotlin
Raw Normal View History

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
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.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 —
// but only PAST-DUE ones. An unresolved log can also be a future dose
// (e.g. "Ta nå" + "Angre" before its time); its own occurrence alarm
// handles it when it actually falls due.
val now = System.currentTimeMillis()
db.doseLogDao().getUnresolved().forEach { log ->
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
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 the user-configured morning time (default 10:00);
* the receiver re-arms the next one. Same PendingIntent re-arming after a
* settings change replaces the alarm, never stacks.
*/
fun armSupplyCheck() {
val minute = no.naiv.meddetsamme.settings.SettingsStore(context).supplyCheckMinuteOfDay
val time = LocalTime.of(minute / 60, minute % 60)
var next = LocalDateTime.of(java.time.LocalDate.now(), time)
if (!next.isAfter(LocalDateTime.now())) next = next.plusDays(1)
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
next.atZone(zone).toInstant().toEpochMilli(),
supplyIntent(),
)
}
}