package com.timesafari.dailynotification import android.content.Context import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.util.concurrent.TimeUnit /** * Manages recovery of alarms and notifications on app launch * Phase 1: Cold start recovery only * * Implements: [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start) * Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart) * * @author Matthew Raymer * @version 1.0.0 */ class ReactivationManager(private val context: Context) { companion object { private const val TAG = "DNP-REACTIVATION" private const val RECOVERY_TIMEOUT_SECONDS = 2L } /** * Perform recovery on app launch * Phase 1: Calls only performColdStartRecovery() when DB is non-empty * * Scenario detection is not implemented in Phase 1 - all app launches * with non-empty DB are treated as cold start. Force stop, boot, and * warm start handling are deferred to Phase 2. * * **Correction**: Must not run when DB is empty (first launch). * * Runs asynchronously with timeout to avoid blocking app startup * * Rollback Safety: If recovery fails, app continues normally */ fun performRecovery() { CoroutineScope(Dispatchers.IO).launch { try { withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) { Log.i(TAG, "Starting app launch recovery (Phase 1: cold start only)") // Correction: Short-circuit if DB is empty (first launch) val db = DailyNotificationDatabase.getDatabase(context) val dbSchedules = db.scheduleDao().getEnabled() if (dbSchedules.isEmpty()) { Log.i(TAG, "No schedules present — skipping recovery (first launch)") return@withTimeout } val result = performColdStartRecovery() Log.i(TAG, "App launch recovery completed: $result") } } catch (e: Exception) { // Rollback: Log error but don't crash Log.e(TAG, "Recovery failed (non-fatal): ${e.message}", e) // Record failure in history (best effort, don't fail if this fails) try { recordRecoveryFailure(e) } catch (historyError: Exception) { Log.w(TAG, "Failed to record recovery failure in history", historyError) } } } } /** * Perform cold start recovery * * Steps: * 1. Detect missed notifications (scheduled_time < now, not delivered) * 2. Mark missed notifications in database * 3. Verify future alarms are scheduled * 4. Reschedule missing future alarms * * @return RecoveryResult with counts */ private suspend fun performColdStartRecovery(): RecoveryResult { val db = DailyNotificationDatabase.getDatabase(context) val currentTime = System.currentTimeMillis() Log.i(TAG, "Cold start recovery: checking for missed notifications") // Step 1: Detect missed notifications val missedNotifications = try { db.notificationContentDao().getNotificationsReadyForDelivery(currentTime) .filter { it.deliveryStatus != "delivered" } } catch (e: Exception) { Log.e(TAG, "Failed to query missed notifications", e) emptyList() } var missedCount = 0 var missedErrors = 0 // Step 2: Mark missed notifications missedNotifications.forEach { notification -> try { // Data integrity check: verify notification is valid if (notification.id.isBlank()) { Log.w(TAG, "Skipping invalid notification: empty ID") return@forEach } // Update delivery status notification.deliveryStatus = "missed" notification.lastDeliveryAttempt = currentTime notification.deliveryAttempts = notification.deliveryAttempts + 1 notification.updatedAt = currentTime db.notificationContentDao().updateNotification(notification) missedCount++ Log.d(TAG, "Marked missed notification: ${notification.id}") } catch (e: Exception) { missedErrors++ Log.e(TAG, "Failed to mark missed notification: ${notification.id}", e) // Continue processing other notifications } } // Step 3: Verify and reschedule future alarms val schedules = try { db.scheduleDao().getEnabled() .filter { it.kind == "notify" } } catch (e: Exception) { Log.e(TAG, "Failed to query schedules", e) emptyList() } var rescheduledCount = 0 var verifiedCount = 0 var rescheduleErrors = 0 schedules.forEach { schedule -> try { // Data integrity check: verify schedule is valid if (schedule.id.isBlank() || schedule.nextRunAt == null) { Log.w(TAG, "Skipping invalid schedule: ${schedule.id}") return@forEach } val nextRunTime = schedule.nextRunAt!! // Only check future alarms if (nextRunTime >= currentTime) { // Verify alarm is scheduled val isScheduled = NotifyReceiver.isAlarmScheduled(context, nextRunTime) if (isScheduled) { verifiedCount++ Log.d(TAG, "Verified scheduled alarm: ${schedule.id} at $nextRunTime") } else { // Reschedule missing alarm rescheduleAlarm(schedule, nextRunTime, db) rescheduledCount++ Log.i(TAG, "Rescheduled missing alarm: ${schedule.id} at $nextRunTime") } } } catch (e: Exception) { rescheduleErrors++ Log.e(TAG, "Failed to verify/reschedule: ${schedule.id}", e) // Continue processing other schedules } } // Step 4: Record recovery in history val result = RecoveryResult( missedCount = missedCount, rescheduledCount = rescheduledCount, verifiedCount = verifiedCount, errors = missedErrors + rescheduleErrors ) recordRecoveryHistory(db, "cold_start", result) Log.i(TAG, "Cold start recovery complete: $result") return result } /** * Reschedule an alarm * * Data integrity: Validates schedule before rescheduling */ private suspend fun rescheduleAlarm( schedule: Schedule, nextRunTime: Long, db: DailyNotificationDatabase ) { try { // Use existing BootReceiver logic for calculating next run time // For now, use schedule.nextRunAt directly val config = UserNotificationConfig( enabled = schedule.enabled, schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", title = "Daily Notification", body = "Your daily update is ready", sound = true, vibration = true, priority = "normal" ) NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) // Update schedule in database (best effort) try { db.scheduleDao().updateRunTimes(schedule.id, schedule.lastRunAt, nextRunTime) } catch (e: Exception) { Log.w(TAG, "Failed to update schedule in database: ${schedule.id}", e) // Don't fail rescheduling if DB update fails } Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime") } catch (e: Exception) { Log.e(TAG, "Failed to reschedule alarm: ${schedule.id}", e) throw e // Re-throw to be caught by caller } } /** * Record recovery in history * * Rollback safety: If history recording fails, log warning but don't fail recovery */ private suspend fun recordRecoveryHistory( db: DailyNotificationDatabase, scenario: String, result: RecoveryResult ) { try { db.historyDao().insert( History( refId = "recovery_${System.currentTimeMillis()}", kind = "recovery", occurredAt = System.currentTimeMillis(), outcome = if (result.errors == 0) "success" else "partial", diagJson = """ { "scenario": "$scenario", "missed_count": ${result.missedCount}, "rescheduled_count": ${result.rescheduledCount}, "verified_count": ${result.verifiedCount}, "errors": ${result.errors} } """.trimIndent() ) ) } catch (e: Exception) { Log.w(TAG, "Failed to record recovery history (non-fatal)", e) // Don't throw - history recording failure shouldn't fail recovery } } /** * Record recovery failure in history */ private suspend fun recordRecoveryFailure(e: Exception) { try { val db = DailyNotificationDatabase.getDatabase(context) db.historyDao().insert( History( refId = "recovery_failure_${System.currentTimeMillis()}", kind = "recovery", occurredAt = System.currentTimeMillis(), outcome = "failure", diagJson = """ { "error": "${e.message}", "error_type": "${e.javaClass.simpleName}" } """.trimIndent() ) ) } catch (historyError: Exception) { // Silently fail - we're already in error handling Log.w(TAG, "Failed to record recovery failure", historyError) } } /** * Data class for recovery results */ private data class RecoveryResult( val missedCount: Int, val rescheduledCount: Int, val verifiedCount: Int, val errors: Int ) { override fun toString(): String { return "missed=$missedCount, rescheduled=$rescheduledCount, verified=$verifiedCount, errors=$errors" } } }