feat(android): implement Phase 1 cold start recovery
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
This commit is contained in:
@@ -97,9 +97,14 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
db = DailyNotificationDatabase.getDatabase(context)
|
||||
Log.i(TAG, "Daily Notification Plugin loaded successfully")
|
||||
|
||||
// Phase 1: Perform app launch recovery (cold start only)
|
||||
// Runs asynchronously, non-blocking, with timeout
|
||||
val reactivationManager = ReactivationManager(context)
|
||||
reactivationManager.performRecovery()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
|
||||
// Don't throw - allow plugin to load but database operations will fail gracefully
|
||||
// Don't throw - allow plugin to load even if recovery fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,50 +102,47 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
||||
|
||||
// Store notification content in database before scheduling alarm
|
||||
// This allows DailyNotificationReceiver to retrieve content via notification ID
|
||||
// FIX: Wrap suspend function calls in coroutine
|
||||
if (!isStaticReminder) {
|
||||
try {
|
||||
// Use runBlocking to call suspend function from non-suspend context
|
||||
// This is acceptable here because we're not in a UI thread and need to ensure
|
||||
// content is stored before scheduling the alarm
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val contentCache = db.contentCacheDao().getLatest()
|
||||
|
||||
// If we have cached content, create a notification content entity
|
||||
if (contentCache != null) {
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
config.body ?: String(contentCache.payload),
|
||||
triggerAtMillis,
|
||||
java.time.ZoneId.systemDefault().id
|
||||
)
|
||||
entity.priority = when (config.priority) {
|
||||
"high", "max" -> 2
|
||||
"low", "min" -> -1
|
||||
else -> 0
|
||||
}
|
||||
entity.vibrationEnabled = config.vibration ?: true
|
||||
entity.soundEnabled = config.sound ?: true
|
||||
entity.deliveryStatus = "pending"
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
|
||||
|
||||
// saveNotificationContent returns CompletableFuture, so we need to wait for it
|
||||
roomStorage.saveNotificationContent(entity).get()
|
||||
Log.d(TAG, "Stored notification content in database: id=$notificationId")
|
||||
}
|
||||
// Phase 1: Always create NotificationContentEntity for recovery tracking
|
||||
// This allows recovery to detect missed notifications even for static reminders
|
||||
// Use runBlocking to call suspend function from non-suspend context
|
||||
// This is acceptable here because we're not in a UI thread and need to ensure
|
||||
// content is stored before scheduling the alarm
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val contentCache = db.contentCacheDao().getLatest()
|
||||
|
||||
// Always create a notification content entity for recovery tracking
|
||||
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
config.body ?: (if (contentCache != null) String(contentCache.payload) else ""),
|
||||
triggerAtMillis,
|
||||
java.time.ZoneId.systemDefault().id
|
||||
)
|
||||
entity.priority = when (config.priority) {
|
||||
"high", "max" -> 2
|
||||
"low", "min" -> -1
|
||||
else -> 0
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
|
||||
entity.vibrationEnabled = config.vibration ?: true
|
||||
entity.soundEnabled = config.sound ?: true
|
||||
entity.deliveryStatus = "pending"
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
entity.ttlSeconds = contentCache?.ttlSeconds?.toLong() ?: (7 * 24 * 60 * 60).toLong() // Default 7 days if no cache
|
||||
|
||||
// saveNotificationContent returns CompletableFuture, so we need to wait for it
|
||||
roomStorage.saveNotificationContent(entity).get()
|
||||
Log.d(TAG, "Stored notification content in database: id=$notificationId (for recovery tracking)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
|
||||
}
|
||||
|
||||
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user