Implements cold start recovery for missed notifications and future alarm verification/rescheduling as specified in Phase 1 directive. Changes: - Add ReactivationManager.kt with cold start recovery logic - Integrate recovery into DailyNotificationPlugin.load() - Fix NotifyReceiver to always store NotificationContentEntity for recovery - Add Phase 1 emulator testing guide and verification doc - Add test-phase1.sh automated test harness Recovery behavior: - Detects missed notifications on app launch - Marks missed notifications in database - Verifies future alarms are scheduled in AlarmManager - Reschedules missing future alarms - Completes within 2-second timeout (non-blocking) Test harness: - Automated script with 4 test cases - UI prompts for plugin configuration - Log parsing for recovery results - Verified on Pixel 8 API 34 emulator Related: - Implements: android-implementation-directive-phase1.md - Requirements: docs/alarms/03-plugin-requirements.md §3.1.2 - Testing: docs/alarms/PHASE1-EMULATOR-TESTING.md - Verification: docs/alarms/PHASE1-VERIFICATION.md
302 lines
11 KiB
Kotlin
302 lines
11 KiB
Kotlin
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"
|
|
}
|
|
}
|
|
}
|
|
|