feat(android): implement Phase 2 force stop detection and recovery
Implements force stop scenario detection and comprehensive alarm recovery. Adds scenario differentiation (FORCE_STOP, BOOT, COLD_START, NONE) to route recovery logic appropriately. Changes: - Add RecoveryScenario enum and detectScenario() method - Add performForceStopRecovery() for force stop scenario - Add alarmsExist() helper to check AlarmManager state - Update isBootRecovery() to ignore flags < 1 second old (emulator quirk fix) - Update BootReceiver to only set boot flag for actual boot events - Add test script step to clear boot flag before testing - Fix compiler warnings (remove unused parameters) Fixes false BOOT detection when alarms are cleared after force stop. Boot flag age validation prevents emulator quirks from triggering BOOT scenario during app launch. Implements: Plugin Requirements §3.1.4 - Force Stop Recovery
This commit is contained in:
@@ -3,6 +3,7 @@ package com.timesafari.dailynotification
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -22,12 +23,30 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
|
when (intent?.action) {
|
||||||
Log.i(TAG, "Boot completed, starting recovery")
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
|
||||||
|
Log.i(TAG, "Boot completed, setting boot flag and starting recovery")
|
||||||
|
|
||||||
|
// Phase 2: Set boot flag for scenario detection
|
||||||
|
// This allows ReactivationManager to detect boot scenario on next app launch
|
||||||
|
// Only set flag for actual boot events, not MY_PACKAGE_REPLACED
|
||||||
|
val prefs = context.getSharedPreferences("dailynotification_recovery", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putLong("last_boot_at", System.currentTimeMillis()).apply()
|
||||||
|
|
||||||
// Phase 3: Use ReactivationManager for boot recovery
|
// Phase 3: Use ReactivationManager for boot recovery
|
||||||
ReactivationManager.runBootRecovery(context)
|
ReactivationManager.runBootRecovery(context)
|
||||||
}
|
}
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||||
|
// App was updated - don't set boot flag, just run recovery
|
||||||
|
// This prevents false BOOT detection when app is reinstalled during testing
|
||||||
|
Log.i(TAG, "Package replaced, running recovery without setting boot flag")
|
||||||
|
ReactivationManager.runBootRecovery(context)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Unhandled intent action: ${intent?.action}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun rescheduleNotifications(context: Context) {
|
private suspend fun rescheduleNotifications(context: Context) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.timesafari.dailynotification
|
package com.timesafari.dailynotification
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -11,15 +14,30 @@ import java.util.concurrent.TimeUnit
|
|||||||
/**
|
/**
|
||||||
* Manages recovery of alarms and notifications on app launch
|
* Manages recovery of alarms and notifications on app launch
|
||||||
* Phase 1: Cold start recovery only
|
* Phase 1: Cold start recovery only
|
||||||
|
* Phase 2: Force stop detection and recovery
|
||||||
*
|
*
|
||||||
* Implements: [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start)
|
* Implements:
|
||||||
|
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start)
|
||||||
|
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../docs/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
|
||||||
* Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
* Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.0.0
|
* @version 2.0.0 - Phase 2: Force stop detection
|
||||||
*/
|
*/
|
||||||
class ReactivationManager(private val context: Context) {
|
class ReactivationManager(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery scenario enum
|
||||||
|
*
|
||||||
|
* Defines the different scenarios that trigger recovery
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DNP-REACTIVATION"
|
private const val TAG = "DNP-REACTIVATION"
|
||||||
private const val RECOVERY_TIMEOUT_SECONDS = 2L
|
private const val RECOVERY_TIMEOUT_SECONDS = 2L
|
||||||
@@ -73,15 +91,15 @@ class ReactivationManager(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
when (schedule.kind) {
|
when (schedule.kind) {
|
||||||
"notify" -> {
|
"notify" -> {
|
||||||
val nextRunTime = calculateNextRunTimeForSchedule(context, schedule, currentTime)
|
val nextRunTime = calculateNextRunTimeForSchedule(schedule, currentTime)
|
||||||
|
|
||||||
if (nextRunTime < currentTime) {
|
if (nextRunTime < currentTime) {
|
||||||
// Past alarm - mark as missed and schedule next occurrence
|
// Past alarm - mark as missed and schedule next occurrence
|
||||||
markMissedNotificationForSchedule(context, schedule, nextRunTime, db)
|
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||||
missedCount++
|
missedCount++
|
||||||
|
|
||||||
// Schedule next occurrence if repeating
|
// Schedule next occurrence if repeating
|
||||||
val nextOccurrence = calculateNextOccurrence(schedule, currentTime)
|
val nextOccurrence = calculateNextOccurrence(currentTime)
|
||||||
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
||||||
rescheduledCount++
|
rescheduledCount++
|
||||||
|
|
||||||
@@ -128,9 +146,9 @@ class ReactivationManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateNextRunTimeForSchedule(context: Context, schedule: Schedule, currentTime: Long): Long {
|
private fun calculateNextRunTimeForSchedule(schedule: Schedule, currentTime: Long): Long {
|
||||||
return when {
|
return when {
|
||||||
schedule.nextRunAt != null -> schedule.nextRunAt!!
|
schedule.nextRunAt != null -> schedule.nextRunAt
|
||||||
schedule.cron != null -> {
|
schedule.cron != null -> {
|
||||||
// Parse cron expression: "minute hour * * *" (daily schedule)
|
// Parse cron expression: "minute hour * * *" (daily schedule)
|
||||||
try {
|
try {
|
||||||
@@ -192,14 +210,13 @@ class ReactivationManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
|
private fun calculateNextOccurrence(fromTime: Long): Long {
|
||||||
// For daily schedules, add 24 hours
|
// For daily schedules, add 24 hours
|
||||||
// This is simplified - production should handle weekly/monthly patterns
|
// This is simplified - production should handle weekly/monthly patterns
|
||||||
return fromTime + (24 * 60 * 60 * 1000L)
|
return fromTime + (24 * 60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun markMissedNotificationForSchedule(
|
private suspend fun markMissedNotificationForSchedule(
|
||||||
context: Context,
|
|
||||||
schedule: Schedule,
|
schedule: Schedule,
|
||||||
scheduledTime: Long,
|
scheduledTime: Long,
|
||||||
db: DailyNotificationDatabase
|
db: DailyNotificationDatabase
|
||||||
@@ -320,15 +337,132 @@ class ReactivationManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect recovery scenario
|
||||||
|
*
|
||||||
|
* Phase 2: Determines which recovery scenario applies based on:
|
||||||
|
* - Database state (empty vs populated)
|
||||||
|
* - AlarmManager state (alarms exist vs cleared)
|
||||||
|
* - Boot recovery flag (set by BootReceiver)
|
||||||
|
*
|
||||||
|
* @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 alarm state first (needed for both force stop and boot detection)
|
||||||
|
val alarmsExist = alarmsExist()
|
||||||
|
|
||||||
|
// When alarms don't exist, distinguish between boot and force stop
|
||||||
|
if (!alarmsExist) {
|
||||||
|
// If boot flag is set AND recent (within 60s), it's a boot scenario
|
||||||
|
// Otherwise, it's force stop (boot flag might be stale or from package replacement)
|
||||||
|
if (isBootRecovery()) {
|
||||||
|
Log.i(TAG, "Boot recovery detected: DB has ${dbSchedules.size} schedules, no alarms exist, boot flag set")
|
||||||
|
return RecoveryScenario.BOOT
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Force stop detected: DB has ${dbSchedules.size} schedules, but no alarms exist")
|
||||||
|
return RecoveryScenario.FORCE_STOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarms exist - check for boot recovery (unlikely but possible if boot recovery already ran)
|
||||||
|
if (isBootRecovery()) {
|
||||||
|
Log.i(TAG, "Boot recovery detected (alarms exist - boot recovery may have already run)")
|
||||||
|
return RecoveryScenario.BOOT
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
if (lastBootAt <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val ageMs = currentTime - lastBootAt
|
||||||
|
|
||||||
|
// Boot flag must be at least 1 second old to avoid emulator quirks
|
||||||
|
// where ACTION_BOOT_COMPLETED fires during app install/launch
|
||||||
|
// Boot flag is valid for 60 seconds after boot
|
||||||
|
// This prevents false positives from stale flags
|
||||||
|
if (ageMs >= 1000 && ageMs < 60000) {
|
||||||
|
// Clear the flag after reading
|
||||||
|
prefs.edit().remove("last_boot_at").apply()
|
||||||
|
Log.d(TAG, "Boot flag valid: age=${ageMs}ms")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ageMs > 0 && ageMs < 1000) {
|
||||||
|
Log.d(TAG, "Boot flag too recent (${ageMs}ms) - likely emulator quirk, ignoring")
|
||||||
|
// Don't clear the flag - let it age naturally
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if alarms exist in AlarmManager
|
||||||
|
*
|
||||||
|
* Uses PendingIntent check to determine if any alarms are scheduled.
|
||||||
|
* This is more reliable than checking 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, NotifyReceiver::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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform recovery on app launch
|
* Perform recovery on app launch
|
||||||
* Phase 1: Calls only performColdStartRecovery() when DB is non-empty
|
* Phase 2: Adds scenario detection and force stop handling
|
||||||
*
|
*
|
||||||
* Scenario detection is not implemented in Phase 1 - all app launches
|
* Detects recovery scenario and routes to appropriate recovery method:
|
||||||
* with non-empty DB are treated as cold start. Force stop, boot, and
|
* - FORCE_STOP: All alarms cleared, reschedule all
|
||||||
* warm start handling are deferred to Phase 2.
|
* - COLD_START: Alarms may exist, verify and reschedule missing
|
||||||
*
|
* - BOOT: Device reboot, same as force stop
|
||||||
* **Correction**: Must not run when DB is empty (first launch).
|
* - NONE: No recovery needed (first launch or warm resume)
|
||||||
*
|
*
|
||||||
* Runs asynchronously with timeout to avoid blocking app startup
|
* Runs asynchronously with timeout to avoid blocking app startup
|
||||||
*
|
*
|
||||||
@@ -338,19 +472,37 @@ class ReactivationManager(private val context: Context) {
|
|||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) {
|
withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) {
|
||||||
Log.i(TAG, "Starting app launch recovery (Phase 1: cold start only)")
|
Log.i(TAG, "Starting app launch recovery (Phase 2)")
|
||||||
|
|
||||||
// Correction: Short-circuit if DB is empty (first launch)
|
// Step 1: Detect scenario
|
||||||
val db = DailyNotificationDatabase.getDatabase(context)
|
val scenario = detectScenario()
|
||||||
val dbSchedules = db.scheduleDao().getEnabled()
|
Log.i(TAG, "Detected scenario: $scenario")
|
||||||
|
|
||||||
if (dbSchedules.isEmpty()) {
|
// Step 2: Handle based on scenario
|
||||||
Log.i(TAG, "No schedules present — skipping recovery (first launch)")
|
when (scenario) {
|
||||||
return@withTimeout
|
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 2: Boot recovery (uses same logic as force stop)
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = performColdStartRecovery()
|
Log.i(TAG, "App launch recovery completed")
|
||||||
Log.i(TAG, "App launch recovery completed: $result")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Rollback: Log error but don't crash
|
// Rollback: Log error but don't crash
|
||||||
@@ -441,7 +593,7 @@ class ReactivationManager(private val context: Context) {
|
|||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextRunTime = schedule.nextRunAt!!
|
val nextRunTime = schedule.nextRunAt
|
||||||
|
|
||||||
// Only check future alarms
|
// Only check future alarms
|
||||||
if (nextRunTime >= currentTime) {
|
if (nextRunTime >= currentTime) {
|
||||||
@@ -479,6 +631,169 @@ class ReactivationManager(private val context: Context) {
|
|||||||
return 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
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// Data integrity check: verify schedule is valid
|
||||||
|
if (schedule.id.isBlank() || schedule.nextRunAt == null) {
|
||||||
|
Log.w(TAG, "Skipping invalid schedule: ${schedule.id}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
var missedCount = 0
|
||||||
|
var rescheduledCount = 0
|
||||||
|
var errors = 0
|
||||||
|
|
||||||
|
// Calculate next run time
|
||||||
|
val nextRunTime = calculateNextRunTimeForSchedule(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
|
||||||
|
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||||
|
missedCount++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors++
|
||||||
|
Log.e(TAG, "Failed to mark missed notification: ${schedule.id}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reschedule next occurrence (always reschedule for daily notifications)
|
||||||
|
try {
|
||||||
|
val nextOccurrence = calculateNextOccurrence(currentTime)
|
||||||
|
rescheduleAlarmForBoot(context, 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 {
|
||||||
|
rescheduleAlarmForBoot(context, schedule, nextRunTime, db)
|
||||||
|
rescheduledCount++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors++
|
||||||
|
Log.e(TAG, "Failed to reschedule future alarm: ${schedule.id}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScheduleRecoveryResult(missedCount, rescheduledCount, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a fetch schedule after force stop
|
||||||
|
*
|
||||||
|
* Handles fetch schedules (kind == "fetch")
|
||||||
|
* For Phase 2, fetch recovery is deferred - just log
|
||||||
|
*
|
||||||
|
* @param schedule Schedule to recover
|
||||||
|
* @param currentTime Current time in milliseconds
|
||||||
|
* @param db Database instance
|
||||||
|
* @return ScheduleRecoveryResult
|
||||||
|
*/
|
||||||
|
private suspend fun recoverFetchSchedule(
|
||||||
|
schedule: Schedule,
|
||||||
|
@Suppress("UNUSED_PARAMETER") currentTime: Long,
|
||||||
|
@Suppress("UNUSED_PARAMETER") db: DailyNotificationDatabase
|
||||||
|
): ScheduleRecoveryResult {
|
||||||
|
// Phase 2: Fetch recovery deferred to Phase 3
|
||||||
|
// For now, just log that fetch schedule was found
|
||||||
|
Log.d(TAG, "Fetch schedule ${schedule.id} will be rescheduled by WorkManager (deferred to Phase 3)")
|
||||||
|
return ScheduleRecoveryResult(rescheduledCount = 0, errors = 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reschedule an alarm
|
* Reschedule an alarm
|
||||||
*
|
*
|
||||||
@@ -588,9 +903,9 @@ class ReactivationManager(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Calculate next run time for a schedule
|
* Calculate next run time for a schedule
|
||||||
*/
|
*/
|
||||||
private fun calculateNextRunTimeForSchedule(context: Context, schedule: Schedule, currentTime: Long): Long {
|
private fun calculateNextRunTimeForSchedule(schedule: Schedule, currentTime: Long): Long {
|
||||||
return when {
|
return when {
|
||||||
schedule.nextRunAt != null -> schedule.nextRunAt!!
|
schedule.nextRunAt != null -> schedule.nextRunAt
|
||||||
schedule.cron != null -> {
|
schedule.cron != null -> {
|
||||||
// Parse cron expression: "minute hour * * *" (daily schedule)
|
// Parse cron expression: "minute hour * * *" (daily schedule)
|
||||||
try {
|
try {
|
||||||
@@ -655,7 +970,7 @@ class ReactivationManager(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Calculate next occurrence for a repeating schedule
|
* Calculate next occurrence for a repeating schedule
|
||||||
*/
|
*/
|
||||||
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
|
private fun calculateNextOccurrence(fromTime: Long): Long {
|
||||||
// For daily schedules, add 24 hours
|
// For daily schedules, add 24 hours
|
||||||
// This is simplified - production should handle weekly/monthly patterns
|
// This is simplified - production should handle weekly/monthly patterns
|
||||||
return fromTime + (24 * 60 * 60 * 1000L)
|
return fromTime + (24 * 60 * 60 * 1000L)
|
||||||
@@ -665,7 +980,6 @@ class ReactivationManager(private val context: Context) {
|
|||||||
* Mark missed notification for a schedule
|
* Mark missed notification for a schedule
|
||||||
*/
|
*/
|
||||||
private suspend fun markMissedNotificationForSchedule(
|
private suspend fun markMissedNotificationForSchedule(
|
||||||
context: Context,
|
|
||||||
schedule: Schedule,
|
schedule: Schedule,
|
||||||
scheduledTime: Long,
|
scheduledTime: Long,
|
||||||
db: DailyNotificationDatabase
|
db: DailyNotificationDatabase
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ test1_force_stop_cleared_alarms() {
|
|||||||
|
|
||||||
pause
|
pause
|
||||||
|
|
||||||
|
substep "Step 4.5: Clear boot flag (prevent false BOOT detection)"
|
||||||
|
# Clear boot flag to ensure force stop detection works correctly
|
||||||
|
# Boot flag might be set from previous runs or emulator quirks
|
||||||
|
adb shell "run-as ${APP_ID} rm -f shared_prefs/dailynotification_recovery.xml 2>/dev/null || true"
|
||||||
|
info "Boot flag cleared (if it existed)"
|
||||||
|
|
||||||
substep "Step 5: Launch app (triggers recovery) and capture logs"
|
substep "Step 5: Launch app (triggers recovery) and capture logs"
|
||||||
clear_logs
|
clear_logs
|
||||||
launch_app
|
launch_app
|
||||||
|
|||||||
Reference in New Issue
Block a user