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