chore: synch this plan

This commit is contained in:
Matthew Raymer
2025-11-25 08:04:53 +00:00
parent 6aa9140f67
commit afbc98f7dc
5 changed files with 2629 additions and 12 deletions

View File

@@ -0,0 +1,780 @@
# 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
## 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**: See [Phase 1](./android-implementation-directive-phase1.md) for cold start recovery, [Full Implementation Directive](./android-implementation-directive.md) for 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