feat(android): implement Phase 3 boot-time recovery
Implements boot-time recovery that restores alarms after device reboot, matching Phase 3 directive and test suite expectations. Changes: - Add ReactivationManager.runBootRecovery() companion method - Update BootReceiver to delegate to ReactivationManager - Handle past alarms: mark as missed, schedule next occurrence - Handle future alarms: reschedule immediately - Use DNP-REACTIVATION tag for all boot recovery logs - Log summary: "Boot recovery complete: missed=X, rescheduled=Y, verified=0, errors=Z" - Record history with scenario=BOOT Recovery behavior: - Loads all schedules from database on boot - Detects past vs future scheduled times - Marks missed notifications for past alarms - Reschedules all alarms (past and future) - Completes within 2-second timeout (non-blocking) - Handles empty DB gracefully (logs "BOOT: No schedules found") Implementation details: - Uses companion object method for static access from BootReceiver - Helper methods for schedule calculation, missed marking, rescheduling - Error handling: non-fatal, continues processing other schedules - History recording with boot_recovery kind and scenario=BOOT Related: - Implements: android-implementation-directive-phase3.md - Requirements: docs/alarms/03-plugin-requirements.md §3.1.1 - Testing: docs/alarms/PHASE3-EMULATOR-TESTING.md - Verification: docs/alarms/PHASE3-VERIFICATION.md
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user