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

133 lines
5.7 KiB
Kotlin
Raw Normal View History

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.med.active) return
val existing = db.doseLogDao().getForOccurrence(doseTimeId, scheduledAt)
val log = when {
existing == null -> {
val id = db.doseLogDao().insert(
DoseLog(
medId = med.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
}
postOccurrence(context, db, scheduledAt)
// Start the automatic re-nag chain only if the user wants repeats; an
// explicit Snooze still re-arms regardless (DoseActions.snooze).
if (repeatReminders(context) && 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
// A future dose can't be overdue — drop stray escalations (defense in
// depth against any path that arms one too early).
if (log.scheduledAtMillis > System.currentTimeMillis()) return
val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount)
// Re-post the whole occurrence so co-timed siblings stay on one card.
postOccurrence(context, db, log.scheduledAtMillis)
// Re-arm the next automatic nag only while repeats are enabled. A nag
// already in flight from a Snooze still fires once (this post), then
// stops here — Snooze stays a single deferral, not an endless chain.
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
AlarmScheduler(context).armEscalation(logId)
}
}
/**
* Post (or replace) the single grouped card for one wall-clock occurrence:
* every med due at that minute, still active (not taken/skipped/muted). If
* nothing's active it clears the card a redundant escalation that raced a
* "Tatt alle" then just tidies up.
*/
private suspend fun postOccurrence(context: Context, db: MedDatabase, scheduledAtMillis: Long) {
val active = db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
val id = Notifications.doseNotificationId(scheduledAtMillis)
val entries = active.mapNotNull { l -> db.medicationDao().getById(l.medId)?.let { it to l } }
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis))
}
private fun repeatReminders(context: Context): Boolean =
no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders
}