# Android Implementation Directive: Phase 1 - Cold Start Recovery **Author**: Matthew Raymer **Date**: November 2025 **Status**: Phase 1 - Minimal Viable Recovery **Version**: 1.0.0 ## Purpose Phase 1 implements **minimal viable app launch recovery** for cold start scenarios. This focuses on detecting and handling missed notifications when the app launches after the process was killed. **Scope**: Phase 1 implements **cold start recovery only**. Force stop detection, warm start optimization, and boot receiver enhancements are **out of scope** for this phase and deferred to later phases. **Reference**: - [Full Implementation Directive](./android-implementation-directive.md) - Complete scope - [Phase 2: Force Stop Recovery](./android-implementation-directive-phase2.md) - Next phase - [Phase 3: Boot Receiver Enhancement](./android-implementation-directive-phase3.md) - Final phase --- ## 1. Acceptance Criteria ### 1.1 Definition of Done **Phase 1 is complete when:** 1. ✅ **On cold start, missed notifications are detected** - Notifications with `scheduled_time < currentTime` and `delivery_status != 'delivered'` are identified - Detection runs automatically on app launch (via `DailyNotificationPlugin.load()`) - Detection completes within 2 seconds (non-blocking) 2. ✅ **Missed notifications are marked in database** - `delivery_status` updated to `'missed'` - `last_delivery_attempt` updated to current time - Status change logged in history table 3. ✅ **Future alarms are verified and rescheduled if missing** - All enabled `notify` schedules checked against AlarmManager - Missing alarms rescheduled using existing `NotifyReceiver.scheduleExactNotification()` - No duplicate alarms created (verified before rescheduling) 4. ✅ **Recovery never crashes the app** - All exceptions caught and logged - Database errors don't propagate - Invalid data handled gracefully 5. ✅ **Recovery is observable** - All recovery actions logged with `DNP-REACTIVATION` tag - Recovery metrics recorded in history table - Logs include counts: missed detected, rescheduled, errors ### 1.2 Success Metrics | Metric | Target | Measurement | |--------|--------|-------------| | Recovery execution time | < 2 seconds | Log timestamp difference | | Missed detection accuracy | 100% | Manual verification via logs | | Reschedule success rate | > 95% | History table outcome field | | Crash rate | 0% | No exceptions propagate to app | ### 1.3 Out of Scope (Phase 1) - ❌ Force stop detection (Phase 2) - ❌ Warm start optimization (Phase 2) - ❌ Boot receiver missed alarm handling (Phase 2) - ❌ Callback event emission (Phase 2) - ❌ Fetch work recovery (Phase 2) --- ## 2. Implementation: ReactivationManager ### 2.1 Create New File **File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt` **Purpose**: Centralized cold start recovery logic ### 2.2 Class Structure ```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 * * @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) } } } } // ... implementation methods below ... } ``` ### 2.3 Cold Start Recovery ```kotlin /** * 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 ?: 0) + 1 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 } /** * 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" } } ``` ### 2.4 Helper Methods ```kotlin /** * 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) } } ``` --- ## 3. Integration: DailyNotificationPlugin ### 3.1 Update `load()` Method **File**: `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt` **Location**: After database initialization (line 98) **Current Code**: ```kotlin override fun load() { super.load() try { if (context == null) { Log.e(TAG, "Context is null, cannot initialize database") return } db = DailyNotificationDatabase.getDatabase(context) Log.i(TAG, "Daily Notification Plugin loaded successfully") } catch (e: Exception) { Log.e(TAG, "Failed to initialize Daily Notification Plugin", e) } } ``` **Updated Code**: ```kotlin override fun load() { super.load() try { if (context == null) { Log.e(TAG, "Context is null, cannot initialize database") return } 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 even if recovery fails } } ``` --- ## 4. Data Integrity Checks ### 4.1 Validation Rules **Notification Validation**: - ✅ `id` must not be blank - ✅ `scheduled_time` must be valid timestamp - ✅ `delivery_status` must be valid enum value **Schedule Validation**: - ✅ `id` must not be blank - ✅ `kind` must be "notify" or "fetch" - ✅ `nextRunAt` must be set for verification - ✅ `enabled` must be true (filtered by DAO) ### 4.2 Orphaned Data Handling **Orphaned Notifications** (no matching schedule): - Log warning but don't fail recovery - Mark as missed if past scheduled time **Orphaned Schedules** (no matching notification content): - Log warning but don't fail recovery - Reschedule if future alarm is missing **Mismatched Data**: - If `NotificationContentEntity.scheduled_time` doesn't match `Schedule.nextRunAt`, use `scheduled_time` for missed detection - Log warning for data inconsistency --- ## 5. Rollback Safety ### 5.1 No-Crash Guarantee **All recovery operations must:** 1. **Catch all exceptions** - Never propagate exceptions to app 2. **Log errors** - All failures logged with context 3. **Continue processing** - One failure doesn't stop recovery 4. **Timeout protection** - Recovery completes within 2 seconds or times out 5. **Best-effort updates** - Database failures don't prevent alarm rescheduling ### 5.2 Error Handling Strategy | Error Type | Handling | Log Level | |------------|----------|-----------| | Database query failure | Return empty list, continue | ERROR | | Invalid notification data | Skip notification, continue | WARN | | Alarm reschedule failure | Log error, continue to next | ERROR | | History recording failure | Log warning, don't fail | WARN | | Timeout | Log timeout, abort recovery | WARN | ### 5.3 Fallback Behavior **If recovery fails completely:** - App continues normally - No alarms are lost (existing alarms remain scheduled) - User can manually trigger recovery via app restart - Error logged in history table (if possible) --- ## 6. Callback Behavior (Phase 1 - Deferred) **Phase 1 does NOT emit callbacks.** Callback behavior is deferred to Phase 2. **Future callback contract** (for Phase 2): | Event | Fired When | Payload | Guarantees | |-------|------------|---------|------------| | `missed_notification` | Missed notification detected | `{notificationId, scheduledTime, detectedAt}` | Fired once per missed notification | | `recovery_complete` | Recovery finished | `{scenario, missedCount, rescheduledCount, errors}` | Fired once per recovery run | **Implementation notes:** - Callbacks will use Capacitor event system - Events batched if multiple missed notifications detected - Callbacks fire after database updates complete --- ## 7. Versioning & Migration ### 7.1 Version Bump **Plugin Version**: Increment patch version (e.g., `1.1.0` → `1.1.1`) **Reason**: New feature (recovery), no breaking changes ### 7.2 Database Migration **No database migration required** for Phase 1. **Existing tables used:** - `notification_content` - Already has `delivery_status` field - `schedules` - Already has `nextRunAt` field - `history` - Already supports recovery events ### 7.3 Backward Compatibility **Phase 1 is backward compatible:** - Existing alarms continue to work - No schema changes - Recovery is additive (doesn't break existing functionality) --- ## 8. Testing Requirements ### 8.1 Test 1: Cold Start Missed Detection **Purpose**: Verify missed notifications are detected and marked. **Steps**: 1. Schedule notification for 2 minutes in future 2. Kill app process: `adb shell am kill com.timesafari.dailynotification` 3. Wait 5 minutes (past scheduled time) 4. Launch app: `adb shell am start -n com.timesafari.dailynotification/.MainActivity` 5. Check logs: `adb logcat -d | grep DNP-REACTIVATION` **Expected**: - ✅ Log shows "Cold start recovery: checking for missed notifications" - ✅ Log shows "Marked missed notification: " - ✅ Database shows `delivery_status = 'missed'` - ✅ History table has recovery entry **Pass Criteria**: Missed notification detected and marked in database. ### 8.2 Test 2: Future Alarm Rescheduling **Purpose**: Verify missing future alarms are rescheduled. **Steps**: 1. Schedule notification for 10 minutes in future 2. Manually cancel alarm: `adb shell dumpsys alarm | grep timesafari` (note request code) 3. Launch app 4. Check logs: `adb logcat -d | grep DNP-REACTIVATION` 5. Verify alarm rescheduled: `adb shell dumpsys alarm | grep timesafari` **Expected**: - ✅ Log shows "Rescheduled missing alarm: " - ✅ AlarmManager shows rescheduled alarm - ✅ No duplicate alarms created **Pass Criteria**: Missing alarm rescheduled, no duplicates. ### 8.3 Test 3: Recovery Timeout **Purpose**: Verify recovery times out gracefully. **Steps**: 1. Create large number of schedules (100+) 2. Launch app 3. Check logs for timeout **Expected**: - ✅ Recovery completes within 2 seconds OR times out - ✅ App doesn't crash - ✅ Partial recovery logged if timeout occurs **Pass Criteria**: Recovery doesn't block app launch. ### 8.4 Test 4: Invalid Data Handling **Purpose**: Verify invalid data doesn't crash recovery. **Steps**: 1. Manually insert invalid notification (empty ID) into database 2. Launch app 3. Check logs **Expected**: - ✅ Invalid notification skipped - ✅ Warning logged - ✅ Recovery continues normally **Pass Criteria**: Invalid data handled gracefully. --- ## 9. Implementation Checklist - [ ] Create `ReactivationManager.kt` file - [ ] Implement `performRecovery()` with timeout - [ ] Implement `performColdStartRecovery()` - [ ] Implement missed notification detection - [ ] Implement missed notification marking - [ ] Implement future alarm verification - [ ] Implement missing alarm rescheduling - [ ] Add data integrity checks - [ ] Add error handling (no-crash guarantee) - [ ] Add recovery history recording - [ ] Update `DailyNotificationPlugin.load()` to call recovery - [ ] Test cold start missed detection - [ ] Test future alarm rescheduling - [ ] Test recovery timeout - [ ] Test invalid data handling - [ ] Verify no duplicate alarms - [ ] Verify recovery doesn't block app launch --- ## 10. Code References **Existing Code to Reuse**: - `NotifyReceiver.scheduleExactNotification()` - Line 92 - `NotifyReceiver.isAlarmScheduled()` - Line 279 - `BootReceiver.calculateNextRunTime()` - Line 103 (for Phase 2) - `NotificationContentDao.getNotificationsReadyForDelivery()` - Line 99 - `ScheduleDao.getEnabled()` - Line 298 **New Code to Create**: - `ReactivationManager.kt` - New file (Phase 1) --- ## 11. Success Criteria Summary **Phase 1 is complete when:** 1. ✅ Missed notifications detected on cold start 2. ✅ Missed notifications marked in database 3. ✅ Future alarms verified and rescheduled if missing 4. ✅ Recovery never crashes app 5. ✅ Recovery completes within 2 seconds 6. ✅ All tests pass 7. ✅ No duplicate alarms created --- ## Related Documentation - [Full Implementation Directive](./android-implementation-directive.md) - Complete scope (all phases) - [Exploration Findings](./exploration-findings-initial.md) - Gap analysis - [Plugin Requirements](./plugin-requirements-implementation.md) - Requirements --- ## Notes - **Incremental approach**: Phase 1 focuses on cold start only. Force stop and boot recovery in Phase 2. - **Safety first**: All recovery operations are non-blocking and non-fatal. - **Observability**: Extensive logging for debugging and monitoring. - **Data integrity**: Validation prevents invalid data from causing failures.