fix(notify): eliminate duplicate alarm scheduling and fix test harness counting

Centralize all notification alarm scheduling through NotifyReceiver.scheduleExactNotification()
with idempotence checks to prevent duplicate alarms. Implement one-alarm policy using
setAlarmClock() only. Fix test harness alarm counting to deduplicate by Alarm handle.

Plugin Changes:
- Add ScheduleSource enum to track scheduling paths (INITIAL_SETUP, ROLLOVER_ON_FIRE, etc.)
- Add DB-level idempotence check before scheduling (prevents logical duplicates)
- Add explicit alarm cancellation before scheduling (safety net)
- Implement one-alarm policy: use setAlarmClock() only, no setExact* fallbacks for same event
- Add deep logging for all AlarmManager calls (variant, requestCode, pendingIntentHash)
- Update all rollover paths (DailyNotificationReceiver, DailyNotificationWorker) to use
  centralized function with ROLLOVER_ON_FIRE source
- Add @JvmStatic annotation to scheduleExactNotification for Java interop

Test Harness Changes:
- Fix get_plugin_alarm_count() to deduplicate by Alarm handle (prevents double-counting
  same alarm in main list and "Next wake from idle" section)
- Update TEST 0 messaging: treat 0 alarms as race condition (inconclusive, not failure)
- Make post-rollover check the authoritative assertion point (only fails on >1 or 0 alarms)
- Remove redundant "Found 0 alarms - test may not be accurate" messages

This fixes the duplicate alarm bug where two distinct AlarmManager entries were created
for the same daily notification, violating the "one notification per day" contract.
This commit is contained in:
Matthew Raymer
2025-12-01 10:09:54 +00:00
parent ba8f98db65
commit fc2f64bae3
13 changed files with 880 additions and 186 deletions

View File

@@ -66,7 +66,13 @@ class BootReceiver : BroadcastReceiver() {
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
)
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
}
}

View File

@@ -634,7 +634,7 @@ open class DailyNotificationPlugin : Plugin() {
// Cancel alarm using the scheduled time (used for request code)
val nextRunAt = schedule.nextRunAt
if (nextRunAt != null && nextRunAt > 0) {
NotifyReceiver.cancelNotification(context, nextRunAt)
NotifyReceiver.cancelNotification(context, scheduleId = schedule.id, triggerAtMillis = nextRunAt)
cancelledAlarms++
}
} catch (e: Exception) {
@@ -810,11 +810,19 @@ open class DailyNotificationPlugin : Plugin() {
val nextRunTime = calculateNextRunTime(cronExpression)
// Generate scheduleId before scheduling (needed for stable requestCode)
val scheduleId = options.getString("id") ?: "daily_reminder_${System.currentTimeMillis()}"
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store schedule in database
val scheduleId = options.getString("id") ?: "daily_reminder_${System.currentTimeMillis()}"
val schedule = Schedule(
id = scheduleId,
kind = "notify",
@@ -1392,7 +1400,9 @@ open class DailyNotificationPlugin : Plugin() {
nextRunTime,
config,
isStaticReminder = true,
reminderId = scheduleId
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.TEST_NOTIFICATION
)
// Always schedule prefetch 2 minutes before notification
@@ -1470,7 +1480,7 @@ open class DailyNotificationPlugin : Plugin() {
val triggerAtMillis = options.getLong("triggerAtMillis") ?: return call.reject("triggerAtMillis is required")
val context = context ?: return call.reject("Context not available")
val isScheduled = NotifyReceiver.isAlarmScheduled(context, triggerAtMillis)
val isScheduled = NotifyReceiver.isAlarmScheduled(context, triggerAtMillis = triggerAtMillis)
val result = JSObject().apply {
put("scheduled", isScheduled)
@@ -1598,12 +1608,21 @@ open class DailyNotificationPlugin : Plugin() {
try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Generate scheduleId before scheduling (needed for stable requestCode)
val scheduleId = "notify_${System.currentTimeMillis()}"
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store schedule in database
val schedule = Schedule(
id = "notify_${System.currentTimeMillis()}",
id = scheduleId,
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
@@ -1687,7 +1706,14 @@ open class DailyNotificationPlugin : Plugin() {
FetchWorker.scheduleFetch(context, contentFetchConfig)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, userNotificationConfig)
val scheduleId = "notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store both schedules
val fetchSchedule = Schedule(
@@ -1896,7 +1922,7 @@ open class DailyNotificationPlugin : Plugin() {
// Only check AlarmManager status for "notify" schedules with nextRunAt
if (schedule.kind == "notify" && schedule.nextRunAt != null) {
val isScheduled = NotifyReceiver.isAlarmScheduled(context, schedule.nextRunAt!!)
val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = schedule.nextRunAt!!)
scheduleJson.put("isActuallyScheduled", isScheduled)
} else {
scheduleJson.put("isActuallyScheduled", false)

View File

@@ -386,6 +386,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
/**
* Schedule the next occurrence of this daily notification
*
* Uses centralized NotifyReceiver.scheduleExactNotification() with ROLLOVER_ON_FIRE source
* to ensure idempotence and proper logging
*
* @param context Application context
* @param content Current notification content
*/
@@ -393,42 +396,114 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
try {
Log.d(TAG, "Scheduling next notification for: " + content.getId());
// Calculate next occurrence (24 hours from now)
// Extract scheduleId from notificationId pattern or use fallback
// Notification IDs are often "daily_${scheduleId}"
String scheduleId = null;
String cronExpression = null;
long nextScheduledTime = content.getScheduledTime() + (24 * 60 * 60 * 1000);
// Create new content for next occurrence
NotificationContent nextContent = new NotificationContent();
nextContent.setTitle(content.getTitle());
nextContent.setBody(content.getBody());
nextContent.setScheduledTime(nextScheduledTime);
nextContent.setSound(content.isSound());
nextContent.setPriority(content.getPriority());
nextContent.setUrl(content.getUrl());
// fetchedAt is set in constructor, no need to set it again
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
String notificationId = content.getId();
if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleId = notificationId; // Use notificationId as scheduleId
} else {
scheduleId = "daily_rollover_" + System.currentTimeMillis();
}
// Save to storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.saveNotificationContent(nextContent);
// Calculate cron from current scheduled time (extract hour:minute)
try {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.setTimeInMillis(content.getScheduledTime());
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
int minute = cal.get(java.util.Calendar.MINUTE);
cronExpression = String.format("%d %d * * *", minute, hour);
// Recalculate next run time from cron (tomorrow at same time)
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
} catch (Exception e) {
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
cronExpression = "0 9 * * *"; // Default to 9 AM
}
// Schedule the notification
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
// Create config for next notification
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
content.getBody(),
content.isSound(),
true, // vibration
content.getPriority() != null ? content.getPriority() : "normal"
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
context,
nextScheduledTime,
config,
false, // isStaticReminder
null, // reminderId
scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
);
boolean scheduled = scheduler.scheduleNotification(nextContent);
if (scheduled) {
Log.i(TAG, "Next notification scheduled successfully");
} else {
Log.e(TAG, "Failed to schedule next notification");
}
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);
} catch (Exception e) {
Log.e(TAG, "Error scheduling next notification", e);
}
}
/**
* Helper to convert HH:mm time to cron expression
*/
private String convertTimeToCron(String clockTime) {
try {
String[] parts = clockTime.split(":");
if (parts.length == 2) {
int hour = Integer.parseInt(parts[0]);
int minute = Integer.parseInt(parts[1]);
return String.format("%d %d * * *", minute, hour);
}
} catch (Exception e) {
Log.w(TAG, "Failed to parse clockTime: " + clockTime, e);
}
return "0 9 * * *"; // Default to 9 AM
}
/**
* Helper to calculate next run time from cron expression
*/
private long calculateNextRunTimeFromCron(String cron) {
try {
String[] parts = cron.trim().split("\\s+");
if (parts.length >= 2) {
int minute = Integer.parseInt(parts[0]);
int hour = Integer.parseInt(parts[1]);
java.util.Calendar calendar = java.util.Calendar.getInstance();
long now = calendar.getTimeInMillis();
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour);
calendar.set(java.util.Calendar.MINUTE, minute);
calendar.set(java.util.Calendar.SECOND, 0);
calendar.set(java.util.Calendar.MILLISECOND, 0);
long nextRun = calendar.getTimeInMillis();
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1);
nextRun = calendar.getTimeInMillis();
}
return nextRun;
}
} catch (Exception e) {
Log.w(TAG, "Failed to calculate next run time from cron: " + cron, e);
}
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L);
}
/**
* Get notification priority constant
*

View File

@@ -236,6 +236,11 @@ public class DailyNotificationScheduler {
*/
private boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) {
try {
// WARNING: This is the OLD scheduler - should be replaced with NotifyReceiver.scheduleExactNotification()
// Deep logging to identify if this path is still being called (should not be for daily notifications)
Log.w(TAG, "LEGACY SCHEDULER CALLED: Scheduling OS alarm: variant=LEGACY_SCHEDULER, triggerTime=" + triggerTime + ", pendingIntentHash=" + pendingIntent.hashCode());
Log.w(TAG, "This should NOT be called for daily notifications - use NotifyReceiver.scheduleExactNotification() instead");
// Enhanced exact alarm scheduling for Android 12+ and Doze mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Use setExactAndAllowWhileIdle for Doze mode compatibility

View File

@@ -540,65 +540,89 @@ public class DailyNotificationWorker extends Worker {
return;
}
// Create new content for next occurrence
NotificationContent nextContent = new NotificationContent();
nextContent.setTitle(content.getTitle());
nextContent.setBody(content.getBody());
nextContent.setScheduledTime(nextScheduledTime);
nextContent.setSound(content.isSound());
nextContent.setPriority(content.getPriority());
nextContent.setUrl(content.getUrl());
// fetchedAt is set in constructor, no need to set it again
// Extract scheduleId from notificationId pattern or use fallback
// Notification IDs are often "daily_${scheduleId}"
String scheduleId = null;
String cronExpression = null;
// Save to Room (authoritative) and legacy storage (compat)
saveNextToRoom(nextContent);
DailyNotificationStorage legacyStorage2 = new DailyNotificationStorage(getApplicationContext());
legacyStorage2.saveNotificationContent(nextContent);
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
String notificationId = content.getId();
if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleId = notificationId; // Use notificationId as scheduleId
} else {
scheduleId = "daily_rollover_" + System.currentTimeMillis();
}
// Schedule the notification
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
getApplicationContext(),
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
// Calculate cron from current scheduled time (extract hour:minute)
try {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.setTimeInMillis(content.getScheduledTime());
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
int minute = cal.get(java.util.Calendar.MINUTE);
cronExpression = String.format("%d %d * * *", minute, hour);
// Recalculate next run time from cron (tomorrow at same time)
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
} catch (Exception e) {
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
cronExpression = "0 9 * * *"; // Default to 9 AM
}
// Create config for next notification
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
content.getBody(),
content.isSound(),
true, // vibration
content.getPriority() != null ? content.getPriority() : "normal"
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
getApplicationContext(),
nextScheduledTime,
config,
false, // isStaticReminder
null, // reminderId
scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
);
boolean scheduled = scheduler.scheduleNotification(nextContent);
// Log next scheduled time in readable format
String nextTimeStr = formatScheduledTime(nextScheduledTime);
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
if (scheduled) {
// Log next scheduled time in readable format
String nextTimeStr = formatScheduledTime(nextScheduledTime);
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr);
// Schedule background fetch for next notification (5 minutes before scheduled time)
try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storageForFetcher,
roomStorageForFetcher
);
// Schedule background fetch for next notification (5 minutes before scheduled time)
try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storageForFetcher,
roomStorageForFetcher
);
// Calculate fetch time (5 minutes before notification)
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
" next_fetch=" + fetchTime +
" next_notification=" + nextScheduledTime);
} else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
" fetch_time=" + fetchTime +
" current=" + currentTime);
fetcher.scheduleImmediateFetch();
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
" error scheduling prefetch", e);
// Calculate fetch time (5 minutes before notification)
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
" next_fetch=" + fetchTime +
" next_notification=" + nextScheduledTime);
} else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
" fetch_time=" + fetchTime +
" current=" + currentTime);
fetcher.scheduleImmediateFetch();
}
} else {
Log.e(TAG, "DN|RESCHEDULE_ERR id=" + content.getId());
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
" error scheduling prefetch", e);
}
} catch (Exception e) {
@@ -737,6 +761,55 @@ public class DailyNotificationWorker extends Worker {
* @param scheduledTime Epoch millis
* @return Formatted time string
*/
/**
* Helper to convert HH:mm time to cron expression
*/
private String convertTimeToCron(String clockTime) {
try {
String[] parts = clockTime.split(":");
if (parts.length == 2) {
int hour = Integer.parseInt(parts[0]);
int minute = Integer.parseInt(parts[1]);
return String.format("%d %d * * *", minute, hour);
}
} catch (Exception e) {
Log.w(TAG, "Failed to parse clockTime: " + clockTime, e);
}
return "0 9 * * *"; // Default to 9 AM
}
/**
* Helper to calculate next run time from cron expression
*/
private long calculateNextRunTimeFromCron(String cron) {
try {
String[] parts = cron.trim().split("\\s+");
if (parts.length >= 2) {
int minute = Integer.parseInt(parts[0]);
int hour = Integer.parseInt(parts[1]);
java.util.Calendar calendar = java.util.Calendar.getInstance();
long now = calendar.getTimeInMillis();
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour);
calendar.set(java.util.Calendar.MINUTE, minute);
calendar.set(java.util.Calendar.SECOND, 0);
calendar.set(java.util.Calendar.MILLISECOND, 0);
long nextRun = calendar.getTimeInMillis();
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1);
nextRun = calendar.getTimeInMillis();
}
return nextRun;
}
} catch (Exception e) {
Log.w(TAG, "Failed to calculate next run time from cron: " + cron, e);
}
// Fallback: use DST-safe calculation
return calculateNextScheduledTime(System.currentTimeMillis());
}
private String formatScheduledTime(long scheduledTime) {
try {
ZonedDateTime zoned = ZonedDateTime.ofInstant(

View File

@@ -23,18 +23,48 @@ import kotlinx.coroutines.runBlocking
* @author Matthew Raymer
* @version 1.1.0
*/
/**
* Source of schedule request - tracks which code path triggered scheduling
* Used for debugging duplicate alarm issues
*/
enum class ScheduleSource {
INITIAL_SETUP, // User schedules initial daily notification
ROLLOVER_ON_FIRE, // Notification fired, scheduling next day
APP_LAUNCH_RECOVERY, // App launched, recovering from DB
BOOT_RECOVERY, // Device booted, recovering from DB
APP_RESUME_INIT, // App resumed, initialization/ensure-schedule path
MANUAL_RESCHEDULE, // Manual reschedule (e.g., time change)
TEST_NOTIFICATION // Test notification scheduling
}
class NotifyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-NOTIFY"
private const val SCHEDULE_TAG = "DNP-SCHEDULE"
private const val CHANNEL_ID = "daily_notifications"
private const val NOTIFICATION_ID = 1001
/**
* Generate unique request code from trigger time
* Uses lower 16 bits of timestamp to ensure uniqueness
* Generate stable request code from scheduleId
* Uses scheduleId hash to ensure same schedule always gets same requestCode
* This prevents duplicate alarms when same schedule is scheduled multiple times
*
* @param scheduleId Stable identifier for the schedule (e.g., "daily_reminder_1")
* @return Request code for PendingIntent (uses lower 16 bits of hash)
*/
private fun getRequestCode(triggerAtMillis: Long): Int {
private fun getRequestCode(scheduleId: String): Int {
// Use scheduleId hash for stability - same schedule = same requestCode
// This ensures FLAG_UPDATE_CURRENT works correctly to replace existing alarms
return (scheduleId.hashCode() and 0xFFFF).toInt()
}
/**
* Legacy: Generate request code from trigger time (for backward compatibility)
* @deprecated Use getRequestCode(scheduleId) instead for stable request codes
*/
@Deprecated("Use getRequestCode(scheduleId) for stable request codes")
private fun getRequestCodeFromTime(triggerAtMillis: Long): Int {
return (triggerAtMillis and 0xFFFF).toInt()
}
@@ -83,24 +113,118 @@ class NotifyReceiver : BroadcastReceiver() {
* FIX: Uses DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
* Stores notification content in database and passes notification ID to receiver
*
* Includes idempotence check to prevent duplicate alarms for same schedule
*
* @param context Application context
* @param triggerAtMillis When to trigger the notification (UTC milliseconds)
* @param config Notification configuration
* @param isStaticReminder Whether this is a static reminder (no content dependency)
* @param reminderId Optional reminder ID for tracking
* @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)
*/
@JvmStatic
fun scheduleExactNotification(
context: Context,
triggerAtMillis: Long,
config: UserNotificationConfig,
isStaticReminder: Boolean = false,
reminderId: String? = null
reminderId: String? = null,
scheduleId: String? = null,
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
) {
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 {
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(
context,
timeBasedRequestCode,
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
}
}
}
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 {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
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 for id=$stableScheduleId at $triggerTimeStr from source=$source")
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
return@runBlocking
}
}
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
}
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.i(SCHEDULE_TAG, "Scheduling next daily alarm: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
// Store notification content in database before scheduling alarm
// Phase 1: Always create NotificationContentEntity for recovery tracking
// This allows recovery to detect missed notifications even for static reminders
@@ -150,6 +274,7 @@ class NotifyReceiver : BroadcastReceiver() {
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
// Also preserve original extras for backward compatibility if needed
putExtra("title", config.title)
putExtra("body", config.body)
@@ -163,8 +288,7 @@ class NotifyReceiver : BroadcastReceiver() {
}
}
// Use unique request code based on trigger time to prevent PendingIntent conflicts
val requestCode = getRequestCode(triggerAtMillis)
// requestCode already computed above for idempotence check
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
@@ -172,12 +296,29 @@ class NotifyReceiver : BroadcastReceiver() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// CRITICAL: Cancel any existing alarm for this requestCode BEFORE scheduling
// This ensures we don't create duplicate alarms if this function is called multiple times
// The idempotence check above should prevent this, but this is a safety net
try {
val existingPendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (existingPendingIntent != null) {
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
alarmManager.cancel(existingPendingIntent)
existingPendingIntent.cancel()
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
}
val currentTime = System.currentTimeMillis()
val delayMs = triggerAtMillis - currentTime
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.i(TAG, "Scheduling alarm: triggerTime=$triggerTimeStr, delayMs=$delayMs, requestCode=$requestCode")
Log.i(TAG, "Scheduling alarm: triggerTime=$triggerTimeStr, delayMs=$delayMs, requestCode=$requestCode, scheduleId=$stableScheduleId")
// Check exact alarm permission before scheduling (Android 12+)
val canScheduleExact = canScheduleExactAlarms(context)
@@ -194,8 +335,9 @@ class NotifyReceiver : BroadcastReceiver() {
}
try {
// Use setAlarmClock() for Android 5.0+ (API 21+) - most reliable method
// Shows alarm icon in status bar and is exempt from doze mode
// ONE-ALARM POLICY: Use only setAlarmClock() for Android 5.0+ (API 21+)
// This is the most reliable method and shows alarm icon in status bar
// Do NOT also call setExactAndAllowWhileIdle or setExact for the same event
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Create show intent for alarm clock (opens app when alarm fires)
// Use package launcher intent to avoid hardcoding MainActivity class name
@@ -213,23 +355,36 @@ class NotifyReceiver : BroadcastReceiver() {
}
val alarmClockInfo = AlarmClockInfo(triggerAtMillis, showPendingIntent)
// Deep logging to identify this specific AlarmManager call
Log.i(SCHEDULE_TAG, "Scheduling OS alarm: variant=ALARM_CLOCK, action=${intent.action}, triggerTime=$triggerAtMillis, requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source, pendingIntentHash=${pendingIntent.hashCode()}, showIntentHash=${showPendingIntent?.hashCode() ?: 0}")
alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)
Log.i(TAG, "Alarm clock scheduled (setAlarmClock): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Fallback to setExactAndAllowWhileIdle for Android 6.0-4.4
// Fallback to setExactAndAllowWhileIdle for Android 6.0-4.4 (pre-LOLLIPOP)
// Deep logging to identify this specific AlarmManager call
Log.i(SCHEDULE_TAG, "Scheduling OS alarm: variant=EXACT_ALLOW_WHILE_IDLE, action=${intent.action}, triggerTime=$triggerAtMillis, requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source, pendingIntentHash=${pendingIntent.hashCode()}")
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Exact alarm scheduled (setExactAndAllowWhileIdle): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} else {
// Fallback to setExact for older versions
// Fallback to setExact for older versions (pre-M)
// Deep logging to identify this specific AlarmManager call
Log.i(SCHEDULE_TAG, "Scheduling OS alarm: variant=EXACT, action=${intent.action}, triggerTime=$triggerAtMillis, requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source, pendingIntentHash=${pendingIntent.hashCode()}")
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
} catch (e: SecurityException) {
@@ -247,15 +402,23 @@ class NotifyReceiver : BroadcastReceiver() {
* Cancel a scheduled notification alarm
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
* @param context Application context
* @param triggerAtMillis The trigger time of the alarm to cancel (required for unique request code)
* @param scheduleId The schedule ID of the alarm to cancel (preferred - uses stable request code)
* @param triggerAtMillis The trigger time of the alarm to cancel (fallback - for backward compatibility)
*/
fun cancelNotification(context: Context, triggerAtMillis: Long) {
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
val requestCode = getRequestCode(triggerAtMillis)
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
triggerAtMillis != null -> getRequestCodeFromTime(triggerAtMillis)
else -> {
Log.e(TAG, "cancelNotification: Must provide either scheduleId or triggerAtMillis")
return
}
}
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
@@ -263,22 +426,30 @@ class NotifyReceiver : BroadcastReceiver() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled: triggerAt=$triggerAtMillis, requestCode=$requestCode")
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
/**
* Check if an alarm is scheduled for the given trigger time
* Check if an alarm is scheduled for the given schedule
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
* @param context Application context
* @param triggerAtMillis The trigger time to check
* @param scheduleId The schedule ID to check (preferred - uses stable request code)
* @param triggerAtMillis The trigger time to check (fallback - for backward compatibility)
* @return true if alarm is scheduled, false otherwise
*/
fun isAlarmScheduled(context: Context, triggerAtMillis: Long): Boolean {
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
val requestCode = getRequestCode(triggerAtMillis)
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
triggerAtMillis != null -> getRequestCodeFromTime(triggerAtMillis)
else -> {
Log.e(TAG, "isAlarmScheduled: Must provide either scheduleId or triggerAtMillis")
return false
}
}
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
@@ -287,8 +458,11 @@ class NotifyReceiver : BroadcastReceiver() {
)
val isScheduled = pendingIntent != null
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
val triggerTimeStr = when {
triggerAtMillis != null -> java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
else -> "scheduleId=$scheduleId"
}
Log.d(TAG, "Alarm check for $triggerTimeStr: scheduled=$isScheduled, requestCode=$requestCode")
return isScheduled

View File

@@ -261,7 +261,13 @@ class ReactivationManager(private val context: Context) {
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
)
// Update schedule in database (best effort)
try {
@@ -440,7 +446,7 @@ class ReactivationManager(private val context: Context) {
// Only check future alarms
if (nextRunTime >= currentTime) {
// Verify alarm is scheduled
val isScheduled = NotifyReceiver.isAlarmScheduled(context, nextRunTime)
val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = nextRunTime)
if (isScheduled) {
verifiedCount++
@@ -496,7 +502,13 @@ class ReactivationManager(private val context: Context) {
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.APP_LAUNCH_RECOVERY
)
// Update schedule in database (best effort)
try {
@@ -718,7 +730,13 @@ class ReactivationManager(private val context: Context) {
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
)
// Update schedule in database (best effort)
try {