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:
Matthew Raymer
2025-11-28 04:43:26 +00:00
parent 87594be5be
commit 945956dc5a
2 changed files with 477 additions and 8 deletions

View File

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

View File

@@ -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
*/