Boot recovery was skipping reschedule when it found an "existing" PendingIntent. AlarmManager alarms are not guaranteed to persist across reboot; on devices that clear them, the skip caused the next notification (initial or rollover) to never fire until the app was opened. Pass skipPendingIntentIdempotence = true for all BOOT_RECOVERY call sites (BootReceiver, ReactivationManager.rescheduleAlarmForBoot) so the alarm is always re-registered after reboot. Setting the same PendingIntent again replaces any existing alarm, so no duplicate alarms.
177 lines
6.9 KiB
Kotlin
177 lines
6.9 KiB
Kotlin
package com.timesafari.dailynotification
|
|
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.util.Log
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
|
|
/**
|
|
* Boot recovery receiver to reschedule notifications after device reboot
|
|
* Implements RECEIVE_BOOT_COMPLETED functionality
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
*/
|
|
class BootReceiver : BroadcastReceiver() {
|
|
|
|
companion object {
|
|
private const val TAG = "DNP-BOOT"
|
|
}
|
|
|
|
override fun onReceive(context: Context, intent: Intent?) {
|
|
when (intent?.action) {
|
|
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
|
|
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) {
|
|
val db = DailyNotificationDatabase.getDatabase(context)
|
|
val enabledSchedules = db.scheduleDao().getEnabled()
|
|
|
|
Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule")
|
|
|
|
enabledSchedules.forEach { schedule ->
|
|
try {
|
|
when (schedule.kind) {
|
|
"fetch" -> {
|
|
// Reschedule WorkManager fetch
|
|
val config = ContentFetchConfig(
|
|
enabled = schedule.enabled,
|
|
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
|
url = null, // Will use mock content
|
|
timeout = 30000,
|
|
retryAttempts = 3,
|
|
retryDelay = 1000,
|
|
callbacks = CallbackConfig()
|
|
)
|
|
FetchWorker.scheduleFetch(context, config)
|
|
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
|
|
}
|
|
"notify" -> {
|
|
// Reschedule AlarmManager notification
|
|
val nextRunTime = calculateNextRunTime(schedule)
|
|
if (nextRunTime > System.currentTimeMillis()) {
|
|
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
|
?: Pair("Daily Notification", "Your daily update is ready")
|
|
val config = UserNotificationConfig(
|
|
enabled = schedule.enabled,
|
|
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
|
title = title,
|
|
body = body,
|
|
sound = true,
|
|
vibration = true,
|
|
priority = "normal"
|
|
)
|
|
NotifyReceiver.scheduleExactNotification(
|
|
context,
|
|
nextRunTime,
|
|
config,
|
|
scheduleId = schedule.id,
|
|
source = ScheduleSource.BOOT_RECOVERY,
|
|
skipPendingIntentIdempotence = true
|
|
)
|
|
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
|
|
}
|
|
}
|
|
else -> {
|
|
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
|
|
}
|
|
}
|
|
|
|
// Record boot recovery in history
|
|
try {
|
|
db.historyDao().insert(
|
|
History(
|
|
refId = "boot_recovery_${System.currentTimeMillis()}",
|
|
kind = "boot_recovery",
|
|
occurredAt = System.currentTimeMillis(),
|
|
outcome = "success",
|
|
diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}"
|
|
)
|
|
)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to record boot recovery", e)
|
|
}
|
|
}
|
|
|
|
private fun calculateNextRunTime(schedule: Schedule): Long {
|
|
val now = System.currentTimeMillis()
|
|
|
|
// Simple implementation - for production, use proper cron parsing
|
|
return when {
|
|
schedule.cron != null -> {
|
|
// Parse cron expression and calculate next run
|
|
// For now, return next day at 9 AM
|
|
now + (24 * 60 * 60 * 1000L)
|
|
}
|
|
schedule.clockTime != null -> {
|
|
// Parse HH:mm and calculate next run
|
|
// For now, return next day at specified time
|
|
now + (24 * 60 * 60 * 1000L)
|
|
}
|
|
else -> {
|
|
// Default to next day at 9 AM
|
|
now + (24 * 60 * 60 * 1000L)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Data classes for configuration (simplified versions)
|
|
*/
|
|
data class ContentFetchConfig(
|
|
val enabled: Boolean,
|
|
val schedule: String,
|
|
val url: String? = null,
|
|
val timeout: Int? = null,
|
|
val retryAttempts: Int? = null,
|
|
val retryDelay: Int? = null,
|
|
val callbacks: CallbackConfig
|
|
)
|
|
|
|
data class UserNotificationConfig(
|
|
val enabled: Boolean,
|
|
val schedule: String,
|
|
val title: String? = null,
|
|
val body: String? = null,
|
|
val sound: Boolean? = null,
|
|
val vibration: Boolean? = null,
|
|
val priority: String? = null
|
|
)
|
|
|
|
data class CallbackConfig(
|
|
val apiService: String? = null,
|
|
val database: String? = null,
|
|
val reporting: String? = null
|
|
)
|