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

112 lines
4.4 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
}
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
// 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 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)
}
}