# Android Implementation Directive: Phase 2 - Force Stop Detection & Recovery **Author**: Matthew Raymer **Date**: November 2025 **Status**: Phase 2 - Force Stop Recovery **Version**: 1.0.0 **Last Synced With Plugin Version**: v1.1.0 **Implements**: [Plugin Requirements §3.1.4 - Force Stop Recovery](./alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only) ## Purpose Phase 2 implements **force stop detection and comprehensive recovery**. This handles the scenario where the user force-stops the app, causing all alarms to be cancelled by the OS. **⚠️ IMPORTANT**: This phase **modifies and extends** the `ReactivationManager` introduced in Phase 1. Do not create a second copy; update the existing class. **Prerequisites**: Phase 1 must be complete (cold start recovery implemented). **Scope**: Force stop detection, scenario differentiation, and full alarm recovery. **Reference**: - [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements - [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts - [Phase 1](./android-implementation-directive-phase1.md) - Prerequisite - [Full Implementation Directive](./android-implementation-directive.md) - Complete scope --- ## 1. Acceptance Criteria ### 1.1 Definition of Done **Phase 2 is complete when:** 1. ✅ **Force stop scenario is detected correctly** - Detection: `(DB schedules count > 0) && (AlarmManager alarms count == 0)` - Detection runs on app launch (via `ReactivationManager`) - False positives avoided (distinguishes from first launch) 2. ✅ **All past alarms are marked as missed** - All schedules with `nextRunAt < currentTime` marked as missed - Missed notifications created/updated in database - History records created for each missed alarm 3. ✅ **All future alarms are rescheduled** - All schedules with `nextRunAt >= currentTime` rescheduled - Repeating schedules calculate next occurrence correctly - No duplicate alarms created 4. ✅ **Recovery handles both notify and fetch schedules** - `notify` schedules rescheduled via AlarmManager - `fetch` schedules rescheduled via WorkManager - Both types recovered completely 5. ✅ **Recovery never crashes the app** - All exceptions caught and logged - Partial recovery logged if some schedules fail - App continues normally even if recovery fails ### 1.2 Success Metrics | Metric | Target | Measurement | |--------|--------|-------------| | Force stop detection accuracy | 100% | Manual verification via logs | | Past alarm recovery rate | 100% | All past alarms marked as missed | | Future alarm recovery rate | > 95% | History table outcome field | | Recovery execution time | < 3 seconds | Log timestamp difference | | Crash rate | 0% | No exceptions propagate to app | ### 1.3 Out of Scope (Phase 2) - ❌ Warm start optimization (Phase 3) - ❌ Boot receiver missed alarm handling (Phase 3) - ❌ Callback event emission (Phase 3) - ❌ User notification of missed alarms (Phase 3) --- ## 2. Implementation: Force Stop Detection ### 2.1 Update ReactivationManager **File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt` **Location**: Add scenario detection after Phase 1 implementation ### 2.2 Scenario Detection **⚠️ Canonical Source**: This method supersedes any earlier scenario detection code shown in the full directive. ```kotlin /** * Detect recovery scenario based on AlarmManager state vs database * * Phase 2: Adds force stop detection * * This is the normative implementation of scenario detection. * * @return RecoveryScenario enum value */ private suspend fun detectScenario(): RecoveryScenario { val db = DailyNotificationDatabase.getDatabase(context) val dbSchedules = db.scheduleDao().getEnabled() // Check for first launch (empty DB) if (dbSchedules.isEmpty()) { Log.d(TAG, "No schedules in database - first launch (NONE)") return RecoveryScenario.NONE } // Check for boot recovery (set by BootReceiver) if (isBootRecovery()) { Log.i(TAG, "Boot recovery detected") return RecoveryScenario.BOOT } // Check for force stop: DB has schedules but no alarms exist if (!alarmsExist()) { Log.i(TAG, "Force stop detected: DB has ${dbSchedules.size} schedules, but no alarms exist") return RecoveryScenario.FORCE_STOP } // Normal cold start: DB has schedules and alarms exist // (Alarms may have fired or may be future alarms - need to verify/resync) Log.d(TAG, "Cold start: DB has ${dbSchedules.size} schedules, alarms exist") return RecoveryScenario.COLD_START } /** * Check if this is a boot recovery scenario * * BootReceiver sets a flag in SharedPreferences when boot completes. * This allows ReactivationManager to detect boot scenario. * * @return true if boot recovery, false otherwise */ private fun isBootRecovery(): Boolean { val prefs = context.getSharedPreferences("dailynotification_recovery", Context.MODE_PRIVATE) val lastBootAt = prefs.getLong("last_boot_at", 0) val currentTime = System.currentTimeMillis() // Boot flag is valid for 60 seconds after boot // This prevents false positives from stale flags if (lastBootAt > 0 && (currentTime - lastBootAt) < 60000) { // Clear the flag after reading prefs.edit().remove("last_boot_at").apply() return true } return false } /** * Check if alarms exist in AlarmManager * * **Correction**: Replaces unreliable nextAlarmClock check with PendingIntent check. * This eliminates false positives from nextAlarmClock. * * @return true if at least one alarm exists, false otherwise */ private fun alarmsExist(): Boolean { return try { // Check if any PendingIntent for our receiver exists // This is more reliable than nextAlarmClock val intent = Intent(context, DailyNotificationReceiver::class.java).apply { action = "com.timesafari.daily.NOTIFICATION" } val pendingIntent = PendingIntent.getBroadcast( context, 0, // Use 0 to check for any alarm intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE ) val exists = pendingIntent != null Log.d(TAG, "Alarm check: alarms exist = $exists") exists } catch (e: Exception) { Log.e(TAG, "Error checking if alarms exist", e) // On error, assume no alarms (conservative for force stop detection) false } } /** * Recovery scenario enum * * **Corrected Model**: Only these four scenarios are supported */ enum class RecoveryScenario { COLD_START, // Process killed, alarms may or may not exist FORCE_STOP, // Alarms cleared, DB still populated BOOT, // Device reboot NONE // No recovery required (warm resume or first launch) } ``` ### 2.3 Update performRecovery() ```kotlin /** * Perform recovery on app launch * Phase 2: Adds force stop handling */ fun performRecovery() { CoroutineScope(Dispatchers.IO).launch { try { withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) { Log.i(TAG, "Starting app launch recovery (Phase 2)") // Step 1: Detect scenario val scenario = detectScenario() Log.i(TAG, "Detected scenario: $scenario") // Step 2: Handle based on scenario when (scenario) { RecoveryScenario.FORCE_STOP -> { // Phase 2: Force stop recovery (new in this phase) val result = performForceStopRecovery() Log.i(TAG, "Force stop recovery completed: $result") } RecoveryScenario.COLD_START -> { // Phase 1: Cold start recovery (reuse existing implementation) val result = performColdStartRecovery() Log.i(TAG, "Cold start recovery completed: $result") } RecoveryScenario.BOOT -> { // Phase 3: Boot recovery (handled via ReactivationManager) // Boot recovery uses same logic as force stop (all alarms wiped) val result = performForceStopRecovery() Log.i(TAG, "Boot recovery completed: $result") } RecoveryScenario.NONE -> { // No recovery needed (warm resume or first launch) Log.d(TAG, "No recovery needed (NONE scenario)") } } Log.i(TAG, "App launch recovery completed") } } catch (e: Exception) { Log.e(TAG, "Recovery failed (non-fatal): ${e.message}", e) try { recordRecoveryFailure(e) } catch (historyError: Exception) { Log.w(TAG, "Failed to record recovery failure in history", historyError) } } } } ``` --- ## 3. Implementation: Force Stop Recovery ### 3.1 Force Stop Recovery Method ```kotlin /** * Perform force stop recovery * * Force stop scenario: ALL alarms were cancelled by OS * Need to: * 1. Mark all past alarms as missed * 2. Reschedule all future alarms * 3. Handle both notify and fetch schedules * * @return RecoveryResult with counts */ private suspend fun performForceStopRecovery(): RecoveryResult { val db = DailyNotificationDatabase.getDatabase(context) val currentTime = System.currentTimeMillis() Log.i(TAG, "Force stop recovery: recovering all schedules") val dbSchedules = try { db.scheduleDao().getEnabled() } catch (e: Exception) { Log.e(TAG, "Failed to query schedules", e) return RecoveryResult(0, 0, 0, 1) } var missedCount = 0 var rescheduledCount = 0 var errors = 0 dbSchedules.forEach { schedule -> try { when (schedule.kind) { "notify" -> { val result = recoverNotifySchedule(schedule, currentTime, db) missedCount += result.missedCount rescheduledCount += result.rescheduledCount errors += result.errors } "fetch" -> { val result = recoverFetchSchedule(schedule, currentTime, db) rescheduledCount += result.rescheduledCount errors += result.errors } else -> { Log.w(TAG, "Unknown schedule kind: ${schedule.kind}") } } } catch (e: Exception) { errors++ Log.e(TAG, "Failed to recover schedule: ${schedule.id}", e) } } val result = RecoveryResult( missedCount = missedCount, rescheduledCount = rescheduledCount, verifiedCount = 0, // Not applicable for force stop errors = errors ) recordRecoveryHistory(db, "force_stop", result) Log.i(TAG, "Force stop recovery complete: $result") return result } /** * Data class for schedule recovery results */ private data class ScheduleRecoveryResult( val missedCount: Int = 0, val rescheduledCount: Int = 0, val errors: Int = 0 ) ``` ### 3.2 Recover Notify Schedule **Behavior**: Handles `kind == "notify"` schedules. Reschedules via AlarmManager. ```kotlin /** * Recover a notify schedule after force stop * * Handles notify schedules (kind == "notify") * * @param schedule Schedule to recover * @param currentTime Current time in milliseconds * @param db Database instance * @return ScheduleRecoveryResult */ private suspend fun recoverNotifySchedule( schedule: Schedule, currentTime: Long, db: DailyNotificationDatabase ): ScheduleRecoveryResult { // Data integrity check if (schedule.id.isBlank()) { Log.w(TAG, "Skipping invalid schedule: empty ID") return ScheduleRecoveryResult(errors = 1) } var missedCount = 0 var rescheduledCount = 0 var errors = 0 // Calculate next run time val nextRunTime = calculateNextRunTime(schedule, currentTime) if (nextRunTime < currentTime) { // Past alarm - was missed during force stop Log.i(TAG, "Past alarm detected: ${schedule.id} scheduled for $nextRunTime") try { // Mark as missed markMissedNotification(schedule, nextRunTime, db) missedCount++ } catch (e: Exception) { errors++ Log.e(TAG, "Failed to mark missed notification: ${schedule.id}", e) } // Reschedule next occurrence if repeating if (isRepeating(schedule)) { try { val nextOccurrence = calculateNextOccurrence(schedule, currentTime) rescheduleAlarm(schedule, nextOccurrence, db) rescheduledCount++ Log.i(TAG, "Rescheduled next occurrence: ${schedule.id} for $nextOccurrence") } catch (e: Exception) { errors++ Log.e(TAG, "Failed to reschedule next occurrence: ${schedule.id}", e) } } } else { // Future alarm - reschedule immediately Log.i(TAG, "Future alarm detected: ${schedule.id} scheduled for $nextRunTime") try { rescheduleAlarm(schedule, nextRunTime, db) rescheduledCount++ } catch (e: Exception) { errors++ Log.e(TAG, "Failed to reschedule future alarm: ${schedule.id}", e) } } return ScheduleRecoveryResult(missedCount, rescheduledCount, errors) } ``` ### 3.3 Recover Fetch Schedule **Behavior**: Handles `kind == "fetch"` schedules. Reschedules via WorkManager. ```kotlin /** * Recover a fetch schedule after force stop * * Handles fetch schedules (kind == "fetch") * * @param schedule Schedule to recover * @param currentTime Current time in milliseconds * @param db Database instance * @return ScheduleRecoveryResult */ private suspend fun recoverFetchSchedule( schedule: Schedule, currentTime: Long, db: DailyNotificationDatabase ): ScheduleRecoveryResult { // Data integrity check if (schedule.id.isBlank()) { Log.w(TAG, "Skipping invalid schedule: empty ID") return ScheduleRecoveryResult(errors = 1) } var rescheduledCount = 0 var errors = 0 try { // Reschedule fetch work via WorkManager val config = ContentFetchConfig( enabled = schedule.enabled, schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", url = null, // Will use registered native fetcher timeout = 30000, retryAttempts = 3, retryDelay = 1000, callbacks = CallbackConfig() ) FetchWorker.scheduleFetch(context, config) rescheduledCount++ Log.i(TAG, "Rescheduled fetch: ${schedule.id}") } catch (e: Exception) { errors++ Log.e(TAG, "Failed to reschedule fetch: ${schedule.id}", e) } return ScheduleRecoveryResult(rescheduledCount = rescheduledCount, errors = errors) } ``` ### 3.4 Helper Methods ```kotlin /** * Mark a notification as missed * * @param schedule Schedule that was missed * @param scheduledTime When the notification was scheduled * @param db Database instance */ private suspend fun markMissedNotification( schedule: Schedule, scheduledTime: Long, db: DailyNotificationDatabase ) { try { // Try to find existing NotificationContentEntity val notificationId = schedule.id val existingNotification = db.notificationContentDao().getNotificationById(notificationId) if (existingNotification != null) { // Update existing notification existingNotification.deliveryStatus = "missed" existingNotification.lastDeliveryAttempt = System.currentTimeMillis() existingNotification.deliveryAttempts = (existingNotification.deliveryAttempts ?: 0) + 1 db.notificationContentDao().updateNotification(existingNotification) Log.d(TAG, "Updated existing notification as missed: $notificationId") } else { // Create missed notification entry // Note: This may not have full content, but marks the missed event Log.w(TAG, "No NotificationContentEntity found for schedule: $notificationId") // Could create a minimal entry here if needed } } catch (e: Exception) { Log.e(TAG, "Failed to mark missed notification: ${schedule.id}", e) throw e } } /** * Calculate next run time from schedule * Uses existing BootReceiver logic if available * * @param schedule Schedule to calculate for * @param currentTime Current time in milliseconds * @return Next run time in milliseconds */ private fun calculateNextRunTime(schedule: Schedule, currentTime: Long): Long { // Prefer nextRunAt if set if (schedule.nextRunAt != null) { return schedule.nextRunAt!! } // Calculate from cron or clockTime // For now, simplified: use BootReceiver logic if available // Otherwise, default to next day at 9 AM return when { schedule.cron != null -> { // TODO: Parse cron and calculate next run // For now, return next day at 9 AM currentTime + (24 * 60 * 60 * 1000L) } schedule.clockTime != null -> { // TODO: Parse HH:mm and calculate next run // For now, return next day at specified time currentTime + (24 * 60 * 60 * 1000L) } else -> { // Default to next day at 9 AM currentTime + (24 * 60 * 60 * 1000L) } } } /** * Check if schedule is repeating * * **Helper Consistency Note**: This helper must remain consistent with any * equivalent methods used in `BootReceiver` (Phase 3). If updated, update both places. * * @param schedule Schedule to check * @return true if repeating, false if one-time */ private fun isRepeating(schedule: Schedule): Boolean { // Schedules with cron or clockTime are repeating return schedule.cron != null || schedule.clockTime != null } /** * Calculate next occurrence for repeating schedule * * **Helper Consistency Note**: This helper must remain consistent with any * equivalent methods used in `BootReceiver` (Phase 3). If updated, update both places. * * @param schedule Schedule to calculate for * @param fromTime Calculate next occurrence after this time * @return Next occurrence time in milliseconds */ private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long { // TODO: Implement proper calculation based on cron/clockTime // For now, simplified: daily schedules add 24 hours return fromTime + (24 * 60 * 60 * 1000L) } ``` --- ## 4. Data Integrity Checks ### 4.1 Force Stop Detection Validation **False Positive Prevention**: - ✅ First launch: `DB schedules count == 0` → Not force stop - ✅ Normal cold start: `AlarmManager has alarms` → Not force stop - ✅ Only detect force stop when: `DB schedules > 0 && AlarmManager alarms == 0` **Edge Cases**: - ✅ All alarms already fired: Still detect as force stop if AlarmManager is empty - ✅ Partial alarm cancellation: Not detected as force stop (handled by cold start recovery) ### 4.2 Schedule Validation **Notify Schedule Validation**: - ✅ `id` must not be blank - ✅ `kind` must be "notify" - ✅ `nextRunAt` or `cron`/`clockTime` must be set **Fetch Schedule Validation**: - ✅ `id` must not be blank - ✅ `kind` must be "fetch" - ✅ `cron` or `clockTime` must be set --- ## 5. Rollback Safety ### 5.1 No-Crash Guarantee **All force stop recovery operations must:** 1. **Catch all exceptions** - Never propagate exceptions to app 2. **Continue processing** - One schedule failure doesn't stop recovery 3. **Log errors** - All failures logged with context 4. **Partial recovery** - Some schedules can recover even if others fail ### 5.2 Error Handling Strategy | Error Type | Handling | Log Level | |------------|----------|-----------| | Schedule query failure | Return empty result, log error | ERROR | | Invalid schedule data | Skip schedule, continue | WARN | | Alarm reschedule failure | Log error, continue to next | ERROR | | Fetch reschedule failure | Log error, continue to next | ERROR | | Missed notification marking failure | Log error, continue | ERROR | | History recording failure | Log warning, don't fail | WARN | --- ## 6. Testing Requirements ### 6.1 Test 1: Force Stop Detection **Purpose**: Verify force stop scenario is detected correctly. **Steps**: 1. Schedule 3 notifications (2 minutes, 5 minutes, 10 minutes in future) 2. Verify alarms scheduled: `adb shell dumpsys alarm | grep timesafari` 3. Force stop app: `adb shell am force-stop com.timesafari.dailynotification` 4. Verify alarms cancelled: `adb shell dumpsys alarm | grep timesafari` (should be empty) 5. Launch app: `adb shell am start -n com.timesafari.dailynotification/.MainActivity` 6. Check logs: `adb logcat -d | grep DNP-REACTIVATION` **Expected**: - ✅ Log shows "Force stop detected: DB has X schedules, AlarmManager has 0 alarms" - ✅ Log shows "Detected scenario: FORCE_STOP" - ✅ Log shows "Force stop recovery: recovering all schedules" **Pass Criteria**: Force stop correctly detected. ### 6.2 Test 2: Past Alarm Recovery **Purpose**: Verify past alarms are marked as missed. **Steps**: 1. Schedule notification for 2 minutes in future 2. Force stop app 3. Wait 5 minutes (past scheduled time) 4. Launch app 5. Check database: `delivery_status = 'missed'` for past alarm **Expected**: - ✅ Past alarm marked as missed in database - ✅ History entry created - ✅ Log shows "Past alarm detected" and "Marked missed notification" **Pass Criteria**: Past alarms correctly marked as missed. ### 6.3 Test 3: Future Alarm Recovery **Purpose**: Verify future alarms are rescheduled. **Steps**: 1. Schedule 3 notifications (5, 10, 15 minutes in future) 2. Force stop app 3. Launch app immediately 4. Verify alarms rescheduled: `adb shell dumpsys alarm | grep timesafari` **Expected**: - ✅ All 3 alarms rescheduled in AlarmManager - ✅ Log shows "Future alarm detected" and "Rescheduled alarm" - ✅ No duplicate alarms created **Pass Criteria**: Future alarms correctly rescheduled. ### 6.4 Test 4: Repeating Schedule Recovery **Purpose**: Verify repeating schedules calculate next occurrence correctly. **Steps**: 1. Schedule daily notification (cron: "0 9 * * *") 2. Force stop app 3. Wait past scheduled time (e.g., wait until 10 AM) 4. Launch app 5. Verify next occurrence scheduled for tomorrow 9 AM **Expected**: - ✅ Past occurrence marked as missed - ✅ Next occurrence scheduled for tomorrow - ✅ Log shows "Rescheduled next occurrence" **Pass Criteria**: Repeating schedules correctly calculate next occurrence. ### 6.5 Test 5: Fetch Schedule Recovery **Purpose**: Verify fetch schedules are recovered. **Steps**: 1. Schedule fetch work (cron: "0 9 * * *") 2. Force stop app 3. Launch app 4. Check WorkManager: `adb shell dumpsys jobscheduler | grep timesafari` **Expected**: - ✅ Fetch work rescheduled in WorkManager - ✅ Log shows "Rescheduled fetch" **Pass Criteria**: Fetch schedules correctly recovered. --- ## 7. Implementation Checklist - [ ] Add `detectScenario()` method to ReactivationManager - [ ] Add `alarmsExist()` method (replaces getActiveAlarmCount) - [ ] Add `isBootRecovery()` method - [ ] Add `RecoveryScenario` enum - [ ] Update `performRecovery()` to handle force stop - [ ] Implement `performForceStopRecovery()` - [ ] Implement `recoverNotifySchedule()` - [ ] Implement `recoverFetchSchedule()` - [ ] Implement `markMissedNotification()` - [ ] Implement `calculateNextRunTime()` (or reuse BootReceiver logic) - [ ] Implement `isRepeating()` - [ ] Implement `calculateNextOccurrence()` - [ ] Add data integrity checks - [ ] Add error handling - [ ] Test force stop detection - [ ] Test past alarm recovery - [ ] Test future alarm recovery - [ ] Test repeating schedule recovery - [ ] Test fetch schedule recovery - [ ] Verify no duplicate alarms --- ## 8. Code References **Existing Code to Reuse**: - `NotifyReceiver.scheduleExactNotification()` - Line 92 - `FetchWorker.scheduleFetch()` - Line 31 - `BootReceiver.calculateNextRunTime()` - Line 103 (for next run calculation) - `ScheduleDao.getEnabled()` - Line 298 - `NotificationContentDao.getNotificationById()` - Line 69 **New Code to Create**: - `detectScenario()` - Add to ReactivationManager - `alarmsExist()` - Add to ReactivationManager (replaces getActiveAlarmCount) - `isBootRecovery()` - Add to ReactivationManager - `performForceStopRecovery()` - Add to ReactivationManager - `recoverNotifySchedule()` - Add to ReactivationManager - `recoverFetchSchedule()` - Add to ReactivationManager --- ## 9. Success Criteria Summary **Phase 2 is complete when:** 1. ✅ Force stop scenario detected correctly 2. ✅ All past alarms marked as missed 3. ✅ All future alarms rescheduled 4. ✅ Both notify and fetch schedules recovered 5. ✅ Repeating schedules calculate next occurrence correctly 6. ✅ Recovery never crashes app 7. ✅ All tests pass --- ## Related Documentation - [Phase 1: Cold Start Recovery](./android-implementation-directive-phase1.md) - Prerequisite - [Full Implementation Directive](./android-implementation-directive.md) - Complete scope - [Exploration Findings](./exploration-findings-initial.md) - Gap analysis --- ## Notes - **Prerequisite**: Phase 1 must be complete before starting Phase 2 - **Detection accuracy**: Force stop detection uses best available method (nextAlarmClock) - **Comprehensive recovery**: Force stop recovery handles ALL schedules (past and future) - **Safety first**: All recovery operations are non-blocking and non-fatal