chore: synch this plan
This commit is contained in:
780
docs/android-implementation-directive-phase2.md
Normal file
780
docs/android-implementation-directive-phase2.md
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user