fix(android): second daily notification not firing after reschedule
Cancel-then-schedule was skipped because the idempotence check still found the cancelled PendingIntent in Android's cache. Skip PendingIntent idempotence on the cancel-then-schedule path so the new schedule is always set. - NotifyReceiver.scheduleExactNotification: add skipPendingIntentIdempotence (used only from scheduleDailyNotification) - ScheduleHelper: pass skipPendingIntentIdempotence=true after cancelNotification(scheduleId) - Version 1.1.2: package.json, CHANGELOG, README, TS/Android refs - docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md: optional app cleanup to use one stable id on both platforms
This commit is contained in:
@@ -2642,15 +2642,17 @@ object ScheduleHelper {
|
||||
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
|
||||
|
||||
// Schedule AlarmManager notification as static reminder
|
||||
// (doesn't require cached content)
|
||||
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
|
||||
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
context,
|
||||
nextRunTime,
|
||||
config,
|
||||
isStaticReminder = true,
|
||||
reminderId = scheduleId,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP
|
||||
source = ScheduleSource.INITIAL_SETUP,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
|
||||
// Always schedule prefetch 2 minutes before notification
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.json.JSONObject
|
||||
* Implements exponential backoff and network constraints
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.1
|
||||
* @version 1.1.2
|
||||
*/
|
||||
class FetchWorker(
|
||||
appContext: Context,
|
||||
@@ -205,7 +205,7 @@ class FetchWorker(
|
||||
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.1.1", // Plugin version
|
||||
"1.1.2", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
title,
|
||||
@@ -301,7 +301,7 @@ class FetchWorker(
|
||||
"timestamp": ${System.currentTimeMillis()},
|
||||
"content": "Daily notification content",
|
||||
"source": "mock_generator",
|
||||
"version": "1.1.1"
|
||||
"version": "1.1.2"
|
||||
}
|
||||
""".trimIndent()
|
||||
return mockData.toByteArray()
|
||||
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
|
||||
* Implements TTL-at-fire logic and notification delivery
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.1
|
||||
* @version 1.1.2
|
||||
*/
|
||||
/**
|
||||
* Source of schedule request - tracks which code path triggered scheduling
|
||||
@@ -122,83 +122,87 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
|
||||
* @param scheduleId Stable identifier for the schedule (used for requestCode stability)
|
||||
* @param source Source of the scheduling request (for debugging duplicate alarms)
|
||||
* @param skipPendingIntentIdempotence If true, skip PendingIntent-based idempotence checks.
|
||||
* Use when the caller has just cancelled this scheduleId (cancel-then-schedule path).
|
||||
* Android may still return the cancelled PendingIntent from cache briefly, which would
|
||||
* incorrectly cause the new schedule to be skipped.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun scheduleExactNotification(
|
||||
context: Context,
|
||||
context: Context,
|
||||
triggerAtMillis: Long,
|
||||
config: UserNotificationConfig,
|
||||
isStaticReminder: Boolean = false,
|
||||
reminderId: String? = null,
|
||||
scheduleId: String? = null,
|
||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
|
||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
|
||||
skipPendingIntentIdempotence: Boolean = false
|
||||
) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
|
||||
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
|
||||
// This ensures same schedule always uses same ID for idempotence checks
|
||||
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
|
||||
|
||||
|
||||
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
||||
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
||||
|
||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
|
||||
// This prevents duplicate alarms when multiple scheduling paths race
|
||||
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
|
||||
|
||||
val requestCode = getRequestCode(stableScheduleId)
|
||||
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
|
||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||
var existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
checkIntent,
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
||||
// This catches cases where different scheduleIds are used for the same time
|
||||
// Try a range of request codes around the trigger time
|
||||
if (existingPendingIntent == null) {
|
||||
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
||||
existingPendingIntent = PendingIntent.getBroadcast(
|
||||
|
||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling.
|
||||
// Skip PendingIntent checks when caller just cancelled this schedule (Android may still
|
||||
// return the cancelled PendingIntent from cache and cause the new schedule to be skipped).
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||
var existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
timeBasedRequestCode,
|
||||
requestCode,
|
||||
checkIntent,
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
// Check 3: Also check if AlarmManager already has an alarm for this exact time
|
||||
// This is a fallback for when PendingIntent checks fail but alarm still exists
|
||||
// We check the next alarm clock time (Android 5.0+)
|
||||
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val nextAlarm = alarmManager.nextAlarmClock
|
||||
if (nextAlarm != null) {
|
||||
val nextAlarmTime = nextAlarm.triggerTime
|
||||
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
||||
// If there's an alarm within 1 minute of our target time, consider it a duplicate
|
||||
if (timeDiff < 60000) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
||||
return
|
||||
|
||||
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
||||
if (existingPendingIntent == null) {
|
||||
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
||||
existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
timeBasedRequestCode,
|
||||
checkIntent,
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
// Check 3: AlarmManager next alarm (Android 5.0+)
|
||||
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val nextAlarm = alarmManager.nextAlarmClock
|
||||
if (nextAlarm != null) {
|
||||
val nextAlarmTime = nextAlarm.triggerTime
|
||||
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
||||
if (timeDiff < 60000) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
|
||||
}
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||
// This prevents logical duplicates before even hitting AlarmManager
|
||||
try {
|
||||
@@ -242,7 +246,7 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.1.1", // Plugin version
|
||||
"1.1.2", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
|
||||
@@ -247,7 +247,7 @@ class ReactivationManager(private val context: Context) {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.1.1", // Plugin version
|
||||
"1.1.2", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -1014,7 +1014,7 @@ class ReactivationManager(private val context: Context) {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.1.1", // Plugin version
|
||||
"1.1.2", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
|
||||
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
|
||||
private final ExecutorService executorService;
|
||||
|
||||
// Plugin version for migration tracking
|
||||
private static final String PLUGIN_VERSION = "1.1.1";
|
||||
private static final String PLUGIN_VERSION = "1.1.2";
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
|
||||
Reference in New Issue
Block a user