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?) {
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
|
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 {
|
// Phase 3: Use ReactivationManager for boot recovery
|
||||||
try {
|
ReactivationManager.runBootRecovery(context)
|
||||||
rescheduleNotifications(context)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to reschedule notifications after boot", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,293 @@ class ReactivationManager(private val context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DNP-REACTIVATION"
|
private const val TAG = "DNP-REACTIVATION"
|
||||||
private const val RECOVERY_TIMEOUT_SECONDS = 2L
|
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
|
* Data class for recovery results
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user