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 }