diff --git a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt index 8d19f1c..545a339 100644 --- a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt @@ -23,15 +23,10 @@ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { - Log.i(TAG, "Boot completed, rescheduling notifications") + Log.i(TAG, "Boot completed, starting recovery") - CoroutineScope(Dispatchers.IO).launch { - try { - rescheduleNotifications(context) - } catch (e: Exception) { - Log.e(TAG, "Failed to reschedule notifications after boot", e) - } - } + // Phase 3: Use ReactivationManager for boot recovery + ReactivationManager.runBootRecovery(context) } } diff --git a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt index ffc2c35..ef6e7c1 100644 --- a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt +++ b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt @@ -23,6 +23,293 @@ class ReactivationManager(private val context: Context) { companion object { private const val TAG = "DNP-REACTIVATION" private const val RECOVERY_TIMEOUT_SECONDS = 2L + + /** + * Run boot-time recovery + * + * Phase 3: Boot recovery that restores alarms after device reboot + * + * Implements: [Plugin Requirements ยง3.1.1 - Boot Event](../docs/alarms/03-plugin-requirements.md#311-boot-event-android-only) + * + * This method is called from BootReceiver when BOOT_COMPLETED is received. + * It runs asynchronously with timeout protection to avoid blocking boot. + * + * Recovery steps: + * 1. Load all schedules from database + * 2. For each schedule: + * - If next run time is in future: reschedule alarm + * - If next run time is in past: mark as missed, schedule next occurrence + * 3. Log summary with counts + * + * @param context Application context + */ + fun runBootRecovery(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + try { + withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) { + Log.i(TAG, "Starting boot recovery") + + val db = DailyNotificationDatabase.getDatabase(context) + val enabledSchedules = try { + db.scheduleDao().getEnabled() + } catch (e: Exception) { + Log.e(TAG, "Failed to load schedules from DB", e) + emptyList() + } + + if (enabledSchedules.isEmpty()) { + Log.i(TAG, "BOOT: No schedules found") + return@withTimeout + } + + Log.i(TAG, "Loaded ${enabledSchedules.size} schedules from DB") + + val currentTime = System.currentTimeMillis() + var missedCount = 0 + var rescheduledCount = 0 + var errors = 0 + + enabledSchedules.forEach { schedule -> + try { + when (schedule.kind) { + "notify" -> { + val nextRunTime = calculateNextRunTimeForSchedule(context, schedule, currentTime) + + if (nextRunTime < currentTime) { + // Past alarm - mark as missed and schedule next occurrence + markMissedNotificationForSchedule(context, schedule, nextRunTime, db) + missedCount++ + + // Schedule next occurrence if repeating + val nextOccurrence = calculateNextOccurrence(schedule, currentTime) + rescheduleAlarmForBoot(context, schedule, nextOccurrence, db) + rescheduledCount++ + + Log.i(TAG, "Marked missed notification: ${schedule.id}") + Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextOccurrence") + } else { + // Future alarm - reschedule immediately + rescheduleAlarmForBoot(context, schedule, nextRunTime, db) + rescheduledCount++ + + Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime") + } + } + "fetch" -> { + // Reschedule fetch work (deferred to Phase 2/3) + // For now, just log + Log.d(TAG, "Fetch schedule ${schedule.id} will be rescheduled by WorkManager") + } + else -> { + Log.w(TAG, "Unknown schedule kind: ${schedule.kind}") + } + } + } catch (e: Exception) { + errors++ + Log.e(TAG, "Failed to recover schedule ${schedule.id}", e) + } + } + + // Record recovery in history + val result = RecoveryResult( + missedCount = missedCount, + rescheduledCount = rescheduledCount, + verifiedCount = 0, // Boot recovery doesn't verify + errors = errors + ) + + recordRecoveryHistoryForBoot(db, result) + + Log.i(TAG, "Boot recovery complete: $result") + } + } catch (e: Exception) { + Log.e(TAG, "Boot recovery failed (non-fatal): ${e.message}", e) + } + } + } + + private fun calculateNextRunTimeForSchedule(context: Context, schedule: Schedule, currentTime: Long): Long { + return when { + schedule.nextRunAt != null -> schedule.nextRunAt!! + schedule.cron != null -> { + // Parse cron expression: "minute hour * * *" (daily schedule) + try { + val parts = schedule.cron.trim().split("\\s+".toRegex()) + if (parts.size >= 2) { + val minute = parts[0].toIntOrNull() ?: 0 + val hour = parts[1].toIntOrNull() ?: 9 + + val calendar = java.util.Calendar.getInstance() + calendar.set(java.util.Calendar.HOUR_OF_DAY, hour) + calendar.set(java.util.Calendar.MINUTE, minute) + calendar.set(java.util.Calendar.SECOND, 0) + calendar.set(java.util.Calendar.MILLISECOND, 0) + + var nextRun = calendar.timeInMillis + if (nextRun <= currentTime) { + calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) + nextRun = calendar.timeInMillis + } + return nextRun + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse cron: ${schedule.cron}", e) + } + // Fallback: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + schedule.clockTime != null -> { + // Parse HH:mm time string + try { + val parts = schedule.clockTime.split(":") + if (parts.size == 2) { + val hour = parts[0].toIntOrNull() ?: 9 + val minute = parts[1].toIntOrNull() ?: 0 + + val calendar = java.util.Calendar.getInstance() + calendar.set(java.util.Calendar.HOUR_OF_DAY, hour) + calendar.set(java.util.Calendar.MINUTE, minute) + calendar.set(java.util.Calendar.SECOND, 0) + calendar.set(java.util.Calendar.MILLISECOND, 0) + + var nextRun = calendar.timeInMillis + if (nextRun <= currentTime) { + calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) + nextRun = calendar.timeInMillis + } + return nextRun + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse clockTime: ${schedule.clockTime}", e) + } + // Fallback: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + else -> { + // Default: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + } + } + + private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long { + // For daily schedules, add 24 hours + // This is simplified - production should handle weekly/monthly patterns + return fromTime + (24 * 60 * 60 * 1000L) + } + + private suspend fun markMissedNotificationForSchedule( + context: Context, + schedule: Schedule, + scheduledTime: Long, + db: DailyNotificationDatabase + ) { + try { + // Find or create NotificationContentEntity for this schedule + val notificationId = "daily_${schedule.id}" + val existing = try { + db.notificationContentDao().getById(notificationId) + } catch (e: Exception) { + null + } + + if (existing != null) { + existing.deliveryStatus = "missed" + existing.lastDeliveryAttempt = System.currentTimeMillis() + existing.deliveryAttempts = existing.deliveryAttempts + 1 + existing.updatedAt = System.currentTimeMillis() + db.notificationContentDao().updateNotification(existing) + } else { + // Create new notification content entry for missed alarm + val notification = NotificationContentEntity( + id = notificationId, + scheduleId = schedule.id, + title = "Daily Notification", + body = "Your daily update is ready", + scheduledTime = scheduledTime, + deliveryStatus = "missed", + fetchTime = System.currentTimeMillis(), + lastDeliveryAttempt = System.currentTimeMillis(), + deliveryAttempts = 1, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + db.notificationContentDao().insert(notification) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to mark missed notification for schedule ${schedule.id}", e) + throw e + } + } + + private suspend fun rescheduleAlarmForBoot( + context: Context, + schedule: Schedule, + nextRunTime: Long, + db: DailyNotificationDatabase + ) { + try { + 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) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to reschedule alarm for schedule ${schedule.id}", e) + throw e + } + } + + private suspend fun recordRecoveryHistoryForBoot( + db: DailyNotificationDatabase, + result: RecoveryResult + ) { + try { + db.historyDao().insert( + History( + refId = "boot_recovery_${System.currentTimeMillis()}", + kind = "boot_recovery", + occurredAt = System.currentTimeMillis(), + outcome = if (result.errors == 0) "success" else "partial", + diagJson = """ + { + "scenario": "BOOT", + "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 boot recovery history (non-fatal)", e) + } + } + + 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" + } + } } /** @@ -284,6 +571,193 @@ class ReactivationManager(private val context: Context) { } } + /** + * Calculate next run time for a schedule + */ + private fun calculateNextRunTimeForSchedule(context: Context, schedule: Schedule, currentTime: Long): Long { + return when { + schedule.nextRunAt != null -> schedule.nextRunAt!! + schedule.cron != null -> { + // Parse cron expression: "minute hour * * *" (daily schedule) + try { + val parts = schedule.cron.trim().split("\\s+".toRegex()) + if (parts.size >= 2) { + val minute = parts[0].toIntOrNull() ?: 0 + val hour = parts[1].toIntOrNull() ?: 9 + + val calendar = java.util.Calendar.getInstance() + calendar.set(java.util.Calendar.HOUR_OF_DAY, hour) + calendar.set(java.util.Calendar.MINUTE, minute) + calendar.set(java.util.Calendar.SECOND, 0) + calendar.set(java.util.Calendar.MILLISECOND, 0) + + var nextRun = calendar.timeInMillis + if (nextRun <= currentTime) { + calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) + nextRun = calendar.timeInMillis + } + return nextRun + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse cron: ${schedule.cron}", e) + } + // Fallback: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + schedule.clockTime != null -> { + // Parse HH:mm time string + try { + val parts = schedule.clockTime.split(":") + if (parts.size == 2) { + val hour = parts[0].toIntOrNull() ?: 9 + val minute = parts[1].toIntOrNull() ?: 0 + + val calendar = java.util.Calendar.getInstance() + calendar.set(java.util.Calendar.HOUR_OF_DAY, hour) + calendar.set(java.util.Calendar.MINUTE, minute) + calendar.set(java.util.Calendar.SECOND, 0) + calendar.set(java.util.Calendar.MILLISECOND, 0) + + var nextRun = calendar.timeInMillis + if (nextRun <= currentTime) { + calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) + nextRun = calendar.timeInMillis + } + return nextRun + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse clockTime: ${schedule.clockTime}", e) + } + // Fallback: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + else -> { + // Default: next day at 9 AM + currentTime + (24 * 60 * 60 * 1000L) + } + } + } + + /** + * Calculate next occurrence for a repeating schedule + */ + private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long { + // For daily schedules, add 24 hours + // This is simplified - production should handle weekly/monthly patterns + return fromTime + (24 * 60 * 60 * 1000L) + } + + /** + * Mark missed notification for a schedule + */ + private suspend fun markMissedNotificationForSchedule( + context: Context, + schedule: Schedule, + scheduledTime: Long, + db: DailyNotificationDatabase + ) { + try { + // Find or create NotificationContentEntity for this schedule + val notificationId = "daily_${schedule.id}" + val existing = try { + db.notificationContentDao().getById(notificationId) + } catch (e: Exception) { + null + } + + if (existing != null) { + existing.deliveryStatus = "missed" + existing.lastDeliveryAttempt = System.currentTimeMillis() + existing.deliveryAttempts = existing.deliveryAttempts + 1 + existing.updatedAt = System.currentTimeMillis() + db.notificationContentDao().updateNotification(existing) + } else { + // Create new notification content entry for missed alarm + val notification = NotificationContentEntity( + id = notificationId, + scheduleId = schedule.id, + title = "Daily Notification", + body = "Your daily update is ready", + scheduledTime = scheduledTime, + deliveryStatus = "missed", + fetchTime = System.currentTimeMillis(), + lastDeliveryAttempt = System.currentTimeMillis(), + deliveryAttempts = 1, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + db.notificationContentDao().insert(notification) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to mark missed notification for schedule ${schedule.id}", e) + throw e + } + } + + /** + * Reschedule alarm for boot recovery + */ + private suspend fun rescheduleAlarmForBoot( + context: Context, + schedule: Schedule, + nextRunTime: Long, + db: DailyNotificationDatabase + ) { + try { + 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) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to reschedule alarm for schedule ${schedule.id}", e) + throw e + } + } + + /** + * Record boot recovery in history + */ + private suspend fun recordRecoveryHistoryForBoot( + db: DailyNotificationDatabase, + result: RecoveryResult + ) { + try { + db.historyDao().insert( + History( + refId = "boot_recovery_${System.currentTimeMillis()}", + kind = "boot_recovery", + occurredAt = System.currentTimeMillis(), + outcome = if (result.errors == 0) "success" else "partial", + diagJson = """ + { + "scenario": "BOOT", + "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 boot recovery history (non-fatal)", e) + } + } + /** * Data class for recovery results */