From 3151a1cc3167e81bfedb99d99c790dd29ac40bda Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 27 Nov 2025 10:01:34 +0000 Subject: [PATCH] feat(android): implement Phase 1 cold start recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DailyNotificationPlugin.kt | 7 +- .../dailynotification/NotifyReceiver.kt | 81 +-- .../dailynotification/ReactivationManager.kt | 301 ++++++++ docs/alarms/PHASE1-EMULATOR-TESTING.md | 686 ++++++++++++++++++ docs/alarms/PHASE1-VERIFICATION.md | 259 +++++++ ...android-implementation-directive-phase1.md | 25 +- test-apps/android-test-app/test-phase1.sh | 560 ++++++++++++++ 7 files changed, 1874 insertions(+), 45 deletions(-) create mode 100644 android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt create mode 100644 docs/alarms/PHASE1-EMULATOR-TESTING.md create mode 100644 docs/alarms/PHASE1-VERIFICATION.md create mode 100755 test-apps/android-test-app/test-phase1.sh diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt index 5a1865b..8a88543 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -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 } } diff --git a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt index e675656..fa8267e 100644 --- a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt @@ -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 diff --git a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt new file mode 100644 index 0000000..ffc2c35 --- /dev/null +++ b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt @@ -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" + } + } +} + diff --git a/docs/alarms/PHASE1-EMULATOR-TESTING.md b/docs/alarms/PHASE1-EMULATOR-TESTING.md new file mode 100644 index 0000000..2be8b98 --- /dev/null +++ b/docs/alarms/PHASE1-EMULATOR-TESTING.md @@ -0,0 +1,686 @@ +# Phase 1 Emulator Testing Guide + +**Author**: Matthew Raymer +**Date**: November 2025 +**Status**: Testing Guide +**Version**: 1.0.0 + +## Purpose + +This guide provides step-by-step instructions for testing Phase 1 (Cold Start Recovery) implementation on an Android emulator. All Phase 1 tests can be run entirely on an emulator using ADB commands. + +--- + +## Latest Known Good Run (Emulator) + +**Environment** + +- Device: Android Emulator – Pixel 8 API 34 +- App ID: `com.timesafari.dailynotification` +- Build: Debug APK from `test-apps/android-test-app` +- Script: `./test-phase1.sh` +- Date: 27 November 2025 + +**Observed Results** + +- ✅ TEST 1: Cold Start Missed Detection + - Logs show: + - `Marked missed notification: daily_` + - `Cold start recovery complete: missed=1, rescheduled=0, verified=0, errors=0` + - "Stored notification content in database" present in logs + - Alarm present in `dumpsys alarm` before kill + +- ✅ TEST 2: Future Alarm Verification / Rescheduling + - Logs show: + - `Rescheduled alarm: daily_ for