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:
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user