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);
|
||||
|
||||
// Schedule the notification
|
||||
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
|
||||
// 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(
|
||||
context,
|
||||
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
|
||||
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(
|
||||
// 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(),
|
||||
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
|
||||
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();
|
||||
|
||||
// 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);
|
||||
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 {
|
||||
|
||||
BIN
lib/bin/main/org/example/Library.class
Normal file
BIN
lib/bin/main/org/example/Library.class
Normal file
Binary file not shown.
BIN
lib/bin/test/org/example/LibraryTest.class
Normal file
BIN
lib/bin/test/org/example/LibraryTest.class
Normal file
Binary file not shown.
@@ -255,13 +255,92 @@ show_alarms() {
|
||||
echo
|
||||
}
|
||||
|
||||
count_alarms() {
|
||||
# Returns count of alarms for our app (cleaned of newlines)
|
||||
local count
|
||||
count="$($ADB_BIN shell dumpsys alarm | grep -c "$APP_ID" 2>/dev/null || echo "0")"
|
||||
# Plugin-specific alarm action (must match AndroidManifest.xml)
|
||||
PLUGIN_ALARM_ACTION="com.timesafari.daily.NOTIFICATION"
|
||||
|
||||
get_plugin_alarm_count() {
|
||||
# Returns count of ONLY the plugin's NOTIFICATION alarms (not prefetch - that uses WorkManager)
|
||||
# Expected: 1 notification alarm per daily schedule
|
||||
#
|
||||
# Uses deduplicating parser to avoid double-counting the same alarm that appears in both:
|
||||
# - Main alarm list
|
||||
# - "Next wake from idle" section (ignored - only counts RTC_WAKEUP blocks)
|
||||
# - Alarm Stats section (ignored - only counts actual alarm blocks)
|
||||
#
|
||||
# Tracks unique Alarm handles to ensure each alarm is counted only once
|
||||
local count app_id
|
||||
app_id="$APP_ID"
|
||||
count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | awk -v app="$app_id" '
|
||||
BEGIN {
|
||||
in_block = 0
|
||||
alarmId = ""
|
||||
isPluginNotification = 0
|
||||
}
|
||||
/^[[:space:]]*RTC_WAKEUP/ {
|
||||
in_block = 1
|
||||
alarmId = ""
|
||||
isPluginNotification = 0
|
||||
if (match($0, /Alarm\{/)) {
|
||||
rest = substr($0, RSTART + RLENGTH)
|
||||
if (match(rest, /^[0-9a-f]+/)) {
|
||||
alarmId = substr(rest, RSTART, RLENGTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
/^[[:space:]]*$/ {
|
||||
if (in_block == 1 && isPluginNotification == 1 && alarmId != "") {
|
||||
seen[alarmId] = 1
|
||||
}
|
||||
in_block = 0
|
||||
alarmId = ""
|
||||
isPluginNotification = 0
|
||||
}
|
||||
in_block == 1 {
|
||||
if ($0 ~ app && $0 ~ /com\.timesafari\.daily\.NOTIFICATION/) {
|
||||
isPluginNotification = 1
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (in_block == 1 && isPluginNotification == 1 && alarmId != "") {
|
||||
seen[alarmId] = 1
|
||||
}
|
||||
count = 0
|
||||
for (id in seen) {
|
||||
count++
|
||||
}
|
||||
print count + 0
|
||||
}
|
||||
' 2>/dev/null || echo "0")"
|
||||
echo "$count" | tr -d '\n\r' | head -1
|
||||
}
|
||||
|
||||
# Alias for backward compatibility
|
||||
get_notification_alarm_count() {
|
||||
get_plugin_alarm_count
|
||||
}
|
||||
|
||||
get_prefetch_work_count() {
|
||||
# Returns count of prefetch WorkManager jobs (not AlarmManager alarms)
|
||||
# Note: Prefetch uses WorkManager, not AlarmManager, so it won't appear in dumpsys alarm
|
||||
# This is a placeholder - actual WorkManager job counting would require different approach
|
||||
local count
|
||||
count="$($ADB_BIN shell dumpsys jobscheduler | grep -c "prefetch" 2>/dev/null || echo "0")"
|
||||
echo "$count" | tr -d '\n\r' | head -1
|
||||
}
|
||||
|
||||
get_system_alarm_count() {
|
||||
# Returns total RTC_WAKEUP alarms on system (for debugging/context)
|
||||
local count
|
||||
count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | grep -c "RTC_WAKEUP" || echo "0")"
|
||||
echo "$count" | tr -d '\n\r' | head -1
|
||||
}
|
||||
|
||||
count_alarms() {
|
||||
# Legacy function: now returns plugin-specific alarm count
|
||||
# Use get_plugin_alarm_count() for clarity, or get_system_alarm_count() for total
|
||||
get_plugin_alarm_count
|
||||
}
|
||||
|
||||
force_stop_app() {
|
||||
info "Forcing stop of app process..."
|
||||
$ADB_BIN shell am force-stop "$APP_ID" || true
|
||||
|
||||
@@ -197,6 +197,171 @@ main() {
|
||||
# Clear logs
|
||||
clear_logs
|
||||
|
||||
# ============================================
|
||||
# TEST 0: Daily Rollover (Core Contract Verification)
|
||||
# ============================================
|
||||
# Note: This test verifies the core "one notification per day" contract
|
||||
# by checking that after a notification fires, the next day's schedule
|
||||
# is correctly computed and scheduled. This is a manual/semi-automated test
|
||||
# as it requires either waiting for the alarm to fire or manipulating time.
|
||||
#
|
||||
# To run: Use test ID "0" or enable manually
|
||||
# ============================================
|
||||
if should_run_test "0" SELECTED_TESTS; then
|
||||
print_header "TEST 0: Daily Rollover Verification"
|
||||
echo "Purpose: Verify that after a notification fires, the next day's"
|
||||
echo " schedule is correctly computed and only ONE alarm exists."
|
||||
echo ""
|
||||
echo "Note: This test verifies the core 'one notification per day' contract."
|
||||
echo " It requires either:"
|
||||
echo " 1. Scheduling a notification for 'now + N seconds' and waiting, OR"
|
||||
echo " 2. Manipulating the emulator clock to cross the fire boundary."
|
||||
echo ""
|
||||
echo " For now, this is a MANUAL test - you'll need to verify the"
|
||||
echo " behavior by checking logs and AlarmManager after a notification fires."
|
||||
echo ""
|
||||
wait_for_user
|
||||
|
||||
print_step "1" "Schedule a test notification for near-future (or use existing)..."
|
||||
launch_app
|
||||
ensure_plugin_configured
|
||||
|
||||
INITIAL_COUNT=$(get_plugin_alarm_count)
|
||||
SYSTEM_COUNT=$(get_system_alarm_count)
|
||||
print_info "Current notification alarms: ${INITIAL_COUNT} (expected before scheduling: 0)"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT} (for context)"
|
||||
print_info "Note: Prefetch uses WorkManager (not AlarmManager), so it won't appear in alarm count"
|
||||
|
||||
if [ "${INITIAL_COUNT}" -eq "0" ] 2>/dev/null; then
|
||||
print_success "✅ No existing notification alarms (clean state)"
|
||||
wait_for_ui_action "In the app UI, schedule a daily notification.
|
||||
|
||||
For this test, you may want to schedule it for a time very soon
|
||||
(e.g., 1-2 minutes from now) to observe the rollover behavior.
|
||||
|
||||
This will schedule:
|
||||
- 1 notification alarm (AlarmManager) for the specified time
|
||||
- 1 prefetch job (WorkManager) for 2 minutes before that time"
|
||||
sleep 3 # Give alarm time to be registered in AlarmManager
|
||||
POST_SCHEDULE_COUNT=$(get_plugin_alarm_count)
|
||||
|
||||
# Check alarm count after scheduling
|
||||
# Note: 0 alarms is likely a race condition (alarm may not be visible yet in dumpsys)
|
||||
# Only treat >1 alarms as a real failure (duplicates)
|
||||
if [ "${POST_SCHEDULE_COUNT}" -eq "0" ] 2>/dev/null; then
|
||||
print_warn "⚠️ Found 0 plugin alarms right after scheduling."
|
||||
print_info "ℹ️ This is likely a race condition – treating as inconclusive, not a failure."
|
||||
print_info "ℹ️ The alarm may not be visible in dumpsys yet. We'll rely on the rollover check."
|
||||
elif [ "${POST_SCHEDULE_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Found 1 notification alarm (expected: 1) – immediate post-schedule check passed."
|
||||
else
|
||||
# count > 1 - this is a real duplicate bug
|
||||
print_warn "⚠️ ⚠️ Found ${POST_SCHEDULE_COUNT} notification alarms (expected: 1) – DUPLICATES DETECTED right after scheduling!"
|
||||
print_warn "This indicates duplicate NOTIFICATION alarms were created (BUG DETECTED)"
|
||||
print_info "For debugging, run:"
|
||||
print_info " adb shell dumpsys alarm | grep -A 5 'com.timesafari.dailynotification' | sed -n '1,80p'"
|
||||
fi
|
||||
INITIAL_COUNT="${POST_SCHEDULE_COUNT}"
|
||||
fi
|
||||
|
||||
# Only show alarm details if we found exactly 1 alarm (skip if 0 due to race condition)
|
||||
if [ "${INITIAL_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single notification alarm scheduled (one per day)"
|
||||
print_info "Note: Prefetch uses WorkManager (not AlarmManager), so it won't appear in alarm count"
|
||||
|
||||
# Show alarm details
|
||||
ALARM_DETAILS=$($ADB_BIN shell dumpsys alarm | grep -A 3 "com.timesafari.dailynotification" | grep -A 3 "com.timesafari.daily.NOTIFICATION" | head -10)
|
||||
if [ -n "${ALARM_DETAILS}" ]; then
|
||||
print_info "Notification alarm details:"
|
||||
echo "${ALARM_DETAILS}" | head -5
|
||||
echo ""
|
||||
|
||||
# Extract trigger time
|
||||
ALARM_TRIGGER_MS=$(echo "${ALARM_DETAILS}" | grep -oE "origWhen [0-9]+" | head -1 | awk '{print $2}')
|
||||
if [ -n "${ALARM_TRIGGER_MS}" ]; then
|
||||
ALARM_TRIGGER_SEC=$((ALARM_TRIGGER_MS / 1000))
|
||||
ALARM_READABLE=$(date -d "@${ALARM_TRIGGER_SEC}" 2>/dev/null || echo "${ALARM_TRIGGER_MS} ms")
|
||||
print_info "Notification alarm scheduled for: ${ALARM_READABLE}"
|
||||
|
||||
# Calculate prefetch time (2 minutes before)
|
||||
PREFETCH_SEC=$((ALARM_TRIGGER_SEC - 120))
|
||||
PREFETCH_READABLE=$(date -d "@${PREFETCH_SEC}" 2>/dev/null || echo "${PREFETCH_SEC}")
|
||||
print_info "Prefetch should be scheduled for: ${PREFETCH_READABLE} (2 minutes before, via WorkManager)"
|
||||
|
||||
# Calculate next day
|
||||
NEXT_DAY_SEC=$((ALARM_TRIGGER_SEC + 86400))
|
||||
NEXT_DAY_READABLE=$(date -d "@${NEXT_DAY_SEC}" 2>/dev/null || echo "${NEXT_DAY_SEC}")
|
||||
print_info "Expected next day notification: ${NEXT_DAY_READABLE} (24 hours later)"
|
||||
fi
|
||||
fi
|
||||
elif [ "${INITIAL_COUNT}" -gt "1" ] 2>/dev/null; then
|
||||
print_warn "⚠️ Found ${INITIAL_COUNT} notification alarms (expected: 1) - DUPLICATES DETECTED!"
|
||||
print_warn "This indicates the duplicate alarm bug - multiple alarms for the same notification"
|
||||
fi
|
||||
# Note: We intentionally don't print anything for INITIAL_COUNT == 0 here
|
||||
# because we already handled it above with the race condition message
|
||||
|
||||
print_step "2" "Manual verification steps..."
|
||||
echo ""
|
||||
echo "To complete this test, you need to:"
|
||||
echo " 1. Wait for the notification to fire (or advance emulator clock)"
|
||||
echo " 2. Check that the plugin:"
|
||||
echo " - Computed the next day's time (24 hours later)"
|
||||
echo " - Scheduled exactly ONE notification alarm (AlarmManager) for tomorrow"
|
||||
echo " - Scheduled exactly ONE prefetch job (WorkManager) for 2 minutes before tomorrow's notification"
|
||||
echo " - Did NOT create duplicate notification alarms"
|
||||
echo " 3. Verify in logs:"
|
||||
echo " - Next run time calculation shows tomorrow's time"
|
||||
echo " - Only one notification alarm scheduled in AlarmManager"
|
||||
echo " - Prefetch job scheduled in WorkManager"
|
||||
echo ""
|
||||
echo "Expected log patterns:"
|
||||
echo " DNP-PLUGIN: Calculated next run time: cron=<time>"
|
||||
echo " DNP-NOTIFY: Scheduling alarm: triggerTime=<tomorrow's time>"
|
||||
echo ""
|
||||
echo "After notification fires, run:"
|
||||
echo " adb shell dumpsys alarm | grep -A 3 'com.timesafari.dailynotification'"
|
||||
echo " adb logcat -d | grep -E 'DNP-PLUGIN|DNP-NOTIFY' | tail -20"
|
||||
echo ""
|
||||
|
||||
wait_for_ui_action "After the notification fires (or you advance the clock),
|
||||
check the logs and AlarmManager to verify:
|
||||
|
||||
1. Only ONE alarm exists (one per day)
|
||||
2. The alarm time is for tomorrow (24 hours later)
|
||||
3. No duplicate alarms were created
|
||||
|
||||
Press Enter when you've verified this (or to skip this test)."
|
||||
|
||||
POST_ROLLOVER_COUNT=$(get_plugin_alarm_count)
|
||||
SYSTEM_FINAL=$(get_system_alarm_count)
|
||||
print_info "Notification alarms after rollover: ${POST_ROLLOVER_COUNT} (expected: 1)"
|
||||
print_info "System/other alarms: ${SYSTEM_FINAL} (for context)"
|
||||
print_info "Note: Prefetch is scheduled via WorkManager (not AlarmManager), so it won't appear in alarm count"
|
||||
|
||||
# After rollover, the state should be stable - this is the real assertion point
|
||||
if [ "${POST_ROLLOVER_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ TEST 0 PASSED: Daily rollover created exactly one NOTIFICATION alarm for tomorrow."
|
||||
print_info "Expected state after rollover:"
|
||||
echo " ✅ 1 notification alarm (AlarmManager) for tomorrow"
|
||||
echo " ✅ 1 prefetch job (WorkManager) for 2 minutes before tomorrow's notification"
|
||||
elif [ "${POST_ROLLOVER_COUNT}" -gt "1" ] 2>/dev/null; then
|
||||
print_warn "⚠️ ⚠️ TEST 0 FAILED: Daily rollover created ${POST_ROLLOVER_COUNT} NOTIFICATION alarms (duplicates)."
|
||||
print_warn "This indicates duplicate NOTIFICATION alarms were created (BUG DETECTED)"
|
||||
print_info "For debugging, run:"
|
||||
print_info " adb shell dumpsys alarm | grep -A 5 'com.timesafari.dailynotification' | sed -n '1,80p'"
|
||||
print_info " adb logcat -d | grep 'DNP-SCHEDULE\|DNP-NOTIFY' | tail -20"
|
||||
else
|
||||
# count is 0 or invalid - rollover may have failed
|
||||
print_warn "⚠️ TEST 0 INCONCLUSIVE: No NOTIFICATION alarm found after rollover – check logs/dumpsys manually."
|
||||
print_info "Check logs for rollover scheduling errors:"
|
||||
print_info " adb logcat -d | grep 'DNP-SCHEDULE\|DNP-NOTIFY' | tail -20"
|
||||
print_info " adb shell dumpsys alarm | grep -A 3 'com.timesafari.dailynotification'"
|
||||
fi
|
||||
|
||||
wait_for_user
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# TEST 1: Force-Stop Recovery - Database Restoration
|
||||
# ============================================
|
||||
@@ -220,13 +385,16 @@ main() {
|
||||
# Step 1: Clean start - verify no lingering alarms
|
||||
# ============================================
|
||||
print_step "1" "Clean start - checking for lingering alarms..."
|
||||
LINGERING_COUNT=$(count_alarms)
|
||||
LINGERING_COUNT=$(get_plugin_alarm_count)
|
||||
SYSTEM_COUNT=$(get_system_alarm_count)
|
||||
if [ "${LINGERING_COUNT}" -gt "0" ] 2>/dev/null; then
|
||||
print_warn "Found ${LINGERING_COUNT} lingering alarm(s) - these may interfere with test"
|
||||
print_warn "Found ${LINGERING_COUNT} lingering plugin alarm(s) - these may interfere with test"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT} (for context)"
|
||||
print_info "Consider uninstalling/reinstalling app for clean state"
|
||||
wait_for_user
|
||||
else
|
||||
print_success "No lingering alarms found (clean state)"
|
||||
print_success "No lingering plugin alarms found (clean state)"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT} (for context)"
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
@@ -246,22 +414,28 @@ main() {
|
||||
print_step "3" "Verifying alarm exists in AlarmManager (BEFORE force-stop)..."
|
||||
sleep 2
|
||||
|
||||
ALARM_COUNT_BEFORE=$(count_alarms)
|
||||
print_info "Alarm count in AlarmManager: ${ALARM_COUNT_BEFORE}"
|
||||
ALARM_COUNT_BEFORE=$(get_plugin_alarm_count)
|
||||
SYSTEM_COUNT_BEFORE=$(get_system_alarm_count)
|
||||
print_info "Plugin alarms: ${ALARM_COUNT_BEFORE} (expected: 1)"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT_BEFORE} (for context)"
|
||||
|
||||
if [ "${ALARM_COUNT_BEFORE}" -eq "0" ] 2>/dev/null; then
|
||||
print_error "No alarms found in AlarmManager - cannot test force-stop recovery"
|
||||
print_error "No plugin alarms found in AlarmManager - cannot test force-stop recovery"
|
||||
print_info "Make sure you clicked 'Test Notification' and wait a moment"
|
||||
wait_for_user
|
||||
# Re-check
|
||||
ALARM_COUNT_BEFORE=$(count_alarms)
|
||||
ALARM_COUNT_BEFORE=$(get_plugin_alarm_count)
|
||||
if [ "${ALARM_COUNT_BEFORE}" -eq "0" ] 2>/dev/null; then
|
||||
print_error "Still no alarms found - aborting test"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_success "✅ Alarms confirmed in AlarmManager (count: ${ALARM_COUNT_BEFORE})"
|
||||
if [ "${ALARM_COUNT_BEFORE}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single plugin alarm confirmed in AlarmManager (one per day)"
|
||||
else
|
||||
print_warn "⚠️ Found ${ALARM_COUNT_BEFORE} plugin alarms (expected: 1) - continuing anyway"
|
||||
fi
|
||||
|
||||
# Capture alarm details for later verification
|
||||
ALARM_DETAILS_BEFORE=$($ADB_BIN shell dumpsys alarm | grep -A 3 "$APP_ID" | head -10)
|
||||
@@ -299,14 +473,16 @@ main() {
|
||||
# ============================================
|
||||
print_step "5" "Verifying alarms are MISSING from AlarmManager (AFTER force-stop)..."
|
||||
sleep 1
|
||||
ALARM_COUNT_AFTER=$(count_alarms)
|
||||
print_info "Alarm count in AlarmManager after force-stop: ${ALARM_COUNT_AFTER}"
|
||||
ALARM_COUNT_AFTER=$(get_plugin_alarm_count)
|
||||
SYSTEM_COUNT_AFTER=$(get_system_alarm_count)
|
||||
print_info "Plugin alarms after force-stop: ${ALARM_COUNT_AFTER} (expected: 0)"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT_AFTER} (for context)"
|
||||
|
||||
if [ "${ALARM_COUNT_AFTER}" -eq "0" ] 2>/dev/null; then
|
||||
print_success "✅ Alarms cleared by force-stop (count: ${ALARM_COUNT_AFTER})"
|
||||
print_success "✅ Plugin alarms cleared by force-stop (count: ${ALARM_COUNT_AFTER})"
|
||||
print_info "This confirms: Force-stop cleared alarms from AlarmManager"
|
||||
else
|
||||
print_warn "⚠️ Alarms still present after force-stop (count: ${ALARM_COUNT_AFTER})"
|
||||
print_warn "⚠️ Plugin alarms still present after force-stop (count: ${ALARM_COUNT_AFTER})"
|
||||
print_info "Some devices/OS versions may not clear alarms on force-stop"
|
||||
print_info "Continuing test anyway - recovery should still work"
|
||||
fi
|
||||
@@ -326,8 +502,10 @@ main() {
|
||||
# ============================================
|
||||
print_step "7" "Verifying recovery rebuilt alarms from database..."
|
||||
sleep 2
|
||||
ALARM_COUNT_RECOVERED=$(count_alarms)
|
||||
print_info "Alarm count in AlarmManager after recovery: ${ALARM_COUNT_RECOVERED}"
|
||||
ALARM_COUNT_RECOVERED=$(get_plugin_alarm_count)
|
||||
SYSTEM_COUNT_RECOVERED=$(get_system_alarm_count)
|
||||
print_info "Plugin alarms after recovery: ${ALARM_COUNT_RECOVERED} (expected: 1)"
|
||||
print_info "System/other alarms: ${SYSTEM_COUNT_RECOVERED} (for context)"
|
||||
|
||||
print_info "Checking recovery logs..."
|
||||
check_recovery_logs
|
||||
@@ -443,40 +621,64 @@ main() {
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# TEST 2: Future Alarm Verification
|
||||
# TEST 2: Schedule Update (One-Per-Day Semantics)
|
||||
# ============================================
|
||||
if should_run_test "2" SELECTED_TESTS; then
|
||||
print_header "TEST 2: Future Alarm Verification"
|
||||
echo "Purpose: Verify future alarms are verified/rescheduled if missing."
|
||||
print_header "TEST 2: Schedule Update Verification"
|
||||
echo "Purpose: Verify that updating the schedule time maintains 'one per day' semantics."
|
||||
echo ""
|
||||
echo "Note: This test verifies that recovery correctly handles multiple alarms."
|
||||
echo " We'll schedule a second alarm to test recovery with multiple schedules."
|
||||
echo "Note: This test verifies that when the schedule time changes, the old alarm"
|
||||
echo " is canceled and only the new one remains (one notification per day)."
|
||||
echo ""
|
||||
wait_for_user
|
||||
|
||||
print_step "1" "Launch app"
|
||||
print_step "1" "Launch app and verify initial schedule"
|
||||
launch_app
|
||||
ensure_plugin_configured
|
||||
|
||||
wait_for_ui_action "In the app UI, click 'Test Notification' to schedule a SECOND notification.
|
||||
# Get initial alarm count
|
||||
INITIAL_ALARM_COUNT=$(get_plugin_alarm_count)
|
||||
SYSTEM_ALARM_COUNT=$(get_system_alarm_count)
|
||||
print_info "Plugin alarms: ${INITIAL_ALARM_COUNT} (expected: 1)"
|
||||
print_info "System/other alarms: ${SYSTEM_ALARM_COUNT} (for context)"
|
||||
|
||||
This creates an additional scheduled notification (you should now have 2 total).
|
||||
This tests recovery behavior when multiple alarms exist in the database."
|
||||
if [ "${INITIAL_ALARM_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Initial alarm confirmed (one per day)"
|
||||
elif [ "${INITIAL_ALARM_COUNT}" -eq "0" ] 2>/dev/null; then
|
||||
print_warn "⚠️ No initial alarm found - scheduling one first..."
|
||||
wait_for_ui_action "In the app UI, schedule a daily notification (e.g., click 'Test Notification')."
|
||||
sleep 2
|
||||
INITIAL_ALARM_COUNT=$(get_plugin_alarm_count)
|
||||
if [ "${INITIAL_ALARM_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Alarm scheduled"
|
||||
else
|
||||
print_error "Failed to schedule initial alarm"
|
||||
wait_for_user
|
||||
fi
|
||||
else
|
||||
print_warn "⚠️ Found ${INITIAL_ALARM_COUNT} plugin alarms (expected: 1) - continuing anyway"
|
||||
fi
|
||||
|
||||
print_step "2" "Verifying alarms are scheduled..."
|
||||
sleep 2
|
||||
print_step "2" "Updating schedule time"
|
||||
wait_for_ui_action "In the app UI, change the schedule time (e.g., from 06:50 to 07:10)
|
||||
and apply the schedule.
|
||||
|
||||
This should cancel the old alarm and schedule a new one at the new time.
|
||||
You should still have exactly 1 alarm (one per day)."
|
||||
|
||||
sleep 3
|
||||
check_alarm_status
|
||||
|
||||
ALARM_COUNT=$(count_alarms)
|
||||
print_info "Found ${ALARM_COUNT} scheduled alarm(s) (expected: 1)"
|
||||
UPDATED_ALARM_COUNT=$(get_plugin_alarm_count)
|
||||
print_info "Plugin alarms after update: ${UPDATED_ALARM_COUNT} (expected: 1)"
|
||||
|
||||
if [ "${ALARM_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single alarm confirmed in AlarmManager"
|
||||
elif [ "${ALARM_COUNT}" -gt "1" ] 2>/dev/null; then
|
||||
print_success "Alarms are scheduled in AlarmManager"
|
||||
if [ "${UPDATED_ALARM_COUNT}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single alarm confirmed after schedule update (one per day maintained)"
|
||||
else
|
||||
print_error "No alarms found in AlarmManager"
|
||||
wait_for_user
|
||||
print_warn "⚠️ Found ${UPDATED_ALARM_COUNT} plugin alarms (expected: 1)"
|
||||
if [ "${UPDATED_ALARM_COUNT}" -gt "1" ] 2>/dev/null; then
|
||||
print_warn "⚠️ Multiple alarms detected - old alarm may not have been canceled"
|
||||
fi
|
||||
fi
|
||||
|
||||
print_step "3" "Killing app and relaunching (triggers recovery)..."
|
||||
@@ -502,18 +704,22 @@ main() {
|
||||
VERIFIED_COUNT=$(echo "${RECOVERY_RESULT}" | grep -oE "verified=[0-9]+" | grep -oE "[0-9]+" || echo "0")
|
||||
|
||||
# Verify alarm count after recovery
|
||||
ALARM_COUNT_AFTER_RECOVERY=$(count_alarms)
|
||||
print_info "Alarm count after recovery: ${ALARM_COUNT_AFTER_RECOVERY} (expected: 1)"
|
||||
ALARM_COUNT_AFTER_RECOVERY=$(get_plugin_alarm_count)
|
||||
print_info "Plugin alarms after recovery: ${ALARM_COUNT_AFTER_RECOVERY} (expected: 1)"
|
||||
|
||||
if [ "${RESCHEDULED_COUNT}" -gt "0" ] 2>/dev/null; then
|
||||
print_success "✅ TEST 2 PASSED: Missing future alarm was detected and rescheduled (rescheduled=${RESCHEDULED_COUNT})!"
|
||||
print_success "✅ TEST 2 PASSED: Missing alarm was detected and rescheduled (rescheduled=${RESCHEDULED_COUNT})!"
|
||||
if [ "${ALARM_COUNT_AFTER_RECOVERY}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single alarm confirmed in AlarmManager after recovery"
|
||||
print_success "✅ Single alarm confirmed after recovery (one per day maintained)"
|
||||
else
|
||||
print_warn "⚠️ Alarm count is ${ALARM_COUNT_AFTER_RECOVERY} (expected: 1)"
|
||||
fi
|
||||
elif [ "${VERIFIED_COUNT}" -gt "0" ] 2>/dev/null; then
|
||||
print_success "✅ TEST 2 PASSED: Future alarm verified in AlarmManager (verified=${VERIFIED_COUNT})!"
|
||||
print_success "✅ TEST 2 PASSED: Alarm verified in AlarmManager (verified=${VERIFIED_COUNT})!"
|
||||
if [ "${ALARM_COUNT_AFTER_RECOVERY}" -eq "1" ] 2>/dev/null; then
|
||||
print_success "✅ Single alarm confirmed in AlarmManager after recovery"
|
||||
print_success "✅ Single alarm confirmed after recovery (one per day maintained)"
|
||||
else
|
||||
print_warn "⚠️ Alarm count is ${ALARM_COUNT_AFTER_RECOVERY} (expected: 1)"
|
||||
fi
|
||||
elif [ "${RESCHEDULED_COUNT}" -eq "0" ] 2>/dev/null && [ "${VERIFIED_COUNT}" -eq "0" ] 2>/dev/null; then
|
||||
print_warn "⚠️ TEST 2: No verification/rescheduling needed (both verified=0 and rescheduled=0)"
|
||||
|
||||
@@ -40,12 +40,18 @@ test1_force_stop_cleared_alarms() {
|
||||
|
||||
substep "Step 2: Verify alarms are scheduled"
|
||||
show_alarms
|
||||
local before_count
|
||||
before_count="$(count_alarms)"
|
||||
info "Alarm count before force stop: $before_count"
|
||||
local before_count system_count
|
||||
before_count="$(get_plugin_alarm_count)"
|
||||
system_count="$(get_system_alarm_count)"
|
||||
info "Plugin alarms before force stop: $before_count (expected: 1)"
|
||||
info "System/other alarms: $system_count (for context)"
|
||||
|
||||
if [[ "$before_count" -eq 0 ]]; then
|
||||
warn "No alarms found before force stop; TEST 1 may not be meaningful."
|
||||
warn "No plugin alarms found before force stop; TEST 1 may not be meaningful."
|
||||
elif [[ "$before_count" -eq 1 ]]; then
|
||||
ok "Single plugin alarm confirmed (one per day)"
|
||||
else
|
||||
warn "Found $before_count plugin alarms (expected: 1)"
|
||||
fi
|
||||
|
||||
pause
|
||||
@@ -54,13 +60,15 @@ test1_force_stop_cleared_alarms() {
|
||||
force_stop_app
|
||||
|
||||
substep "Step 4: Check alarms after force stop"
|
||||
local after_count
|
||||
after_count="$(count_alarms)"
|
||||
info "Alarm count after force stop: $after_count"
|
||||
local after_count system_after
|
||||
after_count="$(get_plugin_alarm_count)"
|
||||
system_after="$(get_system_alarm_count)"
|
||||
info "Plugin alarms after force stop: $after_count (expected: 0)"
|
||||
info "System/other alarms: $system_after (for context)"
|
||||
show_alarms
|
||||
|
||||
if [[ "$after_count" -gt 0 ]]; then
|
||||
warn "Alarms still present after force stop. This device/OS may not clear alarms on force stop."
|
||||
warn "Plugin alarms still present after force stop. This device/OS may not clear alarms on force stop."
|
||||
warn "TEST 1 will continue but may not fully validate FORCE_STOP scenario."
|
||||
fi
|
||||
|
||||
@@ -129,12 +137,18 @@ test2_force_stop_intact_alarms() {
|
||||
|
||||
substep "Step 2: Verify alarms are scheduled"
|
||||
show_alarms
|
||||
local before
|
||||
before="$(count_alarms)"
|
||||
info "Alarm count before stop: $before"
|
||||
local before system_before
|
||||
before="$(get_plugin_alarm_count)"
|
||||
system_before="$(get_system_alarm_count)"
|
||||
info "Plugin alarms before stop: $before (expected: 1)"
|
||||
info "System/other alarms: $system_before (for context)"
|
||||
|
||||
if [[ "$before" -eq 0 ]]; then
|
||||
warn "No alarms found; TEST 2 may not be meaningful."
|
||||
warn "No plugin alarms found; TEST 2 may not be meaningful."
|
||||
elif [[ "$before" -eq 1 ]]; then
|
||||
ok "Single plugin alarm confirmed (one per day)"
|
||||
else
|
||||
warn "Found $before plugin alarms (expected: 1)"
|
||||
fi
|
||||
|
||||
pause
|
||||
@@ -146,9 +160,11 @@ test2_force_stop_intact_alarms() {
|
||||
ok "Kill signal sent (soft stop)"
|
||||
|
||||
substep "Step 4: Verify alarms are still scheduled"
|
||||
local after
|
||||
after="$(count_alarms)"
|
||||
info "Alarm count after soft stop: $after"
|
||||
local after system_after
|
||||
after="$(get_plugin_alarm_count)"
|
||||
system_after="$(get_system_alarm_count)"
|
||||
info "Plugin alarms after soft stop: $after (expected: 1)"
|
||||
info "System/other alarms: $system_after (for context)"
|
||||
show_alarms
|
||||
|
||||
if [[ "$after" -eq 0 ]]; then
|
||||
|
||||
@@ -52,12 +52,18 @@ test1_boot_future_alarms() {
|
||||
|
||||
substep "Step 2: Verify alarms are scheduled"
|
||||
show_alarms
|
||||
local before_count
|
||||
before_count="$(count_alarms)"
|
||||
info "Alarm count before reboot: $before_count"
|
||||
local before_count system_before
|
||||
before_count="$(get_plugin_alarm_count)"
|
||||
system_before="$(get_system_alarm_count)"
|
||||
info "Plugin alarms before reboot: $before_count (expected: 1)"
|
||||
info "System/other alarms: $system_before (for context)"
|
||||
|
||||
if [[ "$before_count" -eq 0 ]]; then
|
||||
warn "No alarms found before reboot; TEST 1 may not be meaningful."
|
||||
warn "No plugin alarms found before reboot; TEST 1 may not be meaningful."
|
||||
elif [[ "$before_count" -eq 1 ]]; then
|
||||
ok "Single plugin alarm confirmed (one per day)"
|
||||
else
|
||||
warn "Found $before_count plugin alarms (expected: 1)"
|
||||
fi
|
||||
|
||||
pause
|
||||
@@ -96,9 +102,11 @@ test1_boot_future_alarms() {
|
||||
|
||||
substep "Step 5: Verify alarms were recreated"
|
||||
show_alarms
|
||||
local after_count
|
||||
after_count="$(count_alarms)"
|
||||
info "Alarm count after boot: $after_count"
|
||||
local after_count system_after
|
||||
after_count="$(get_plugin_alarm_count)"
|
||||
system_after="$(get_system_alarm_count)"
|
||||
info "Plugin alarms after boot: $after_count (expected: 1)"
|
||||
info "System/other alarms: $system_after (for context)"
|
||||
|
||||
if [[ "$scenario" == "$BOOT_SCENARIO_VALUE" && "$rescheduled" -gt 0 ]]; then
|
||||
ok "TEST 1 PASSED: Boot recovery detected and alarms rescheduled (scenario=$scenario, rescheduled=$rescheduled)."
|
||||
@@ -276,12 +284,18 @@ test4_silent_boot_recovery() {
|
||||
|
||||
substep "Step 2: Verify alarms are scheduled"
|
||||
show_alarms
|
||||
local before_count
|
||||
before_count="$(count_alarms)"
|
||||
info "Alarm count before reboot: $before_count"
|
||||
local before_count system_before
|
||||
before_count="$(get_plugin_alarm_count)"
|
||||
system_before="$(get_system_alarm_count)"
|
||||
info "Plugin alarms before reboot: $before_count (expected: 1)"
|
||||
info "System/other alarms: $system_before (for context)"
|
||||
|
||||
if [[ "$before_count" -eq 0 ]]; then
|
||||
warn "No alarms found; TEST 4 may not be meaningful."
|
||||
warn "No plugin alarms found; TEST 4 may not be meaningful."
|
||||
elif [[ "$before_count" -eq 1 ]]; then
|
||||
ok "Single plugin alarm confirmed (one per day)"
|
||||
else
|
||||
warn "Found $before_count plugin alarms (expected: 1)"
|
||||
fi
|
||||
|
||||
pause
|
||||
@@ -317,9 +331,11 @@ test4_silent_boot_recovery() {
|
||||
|
||||
substep "Step 5: Verify alarms were recreated (without opening app)"
|
||||
show_alarms
|
||||
local after_count
|
||||
after_count="$(count_alarms)"
|
||||
info "Alarm count after boot (app never opened): $after_count"
|
||||
local after_count system_after
|
||||
after_count="$(get_plugin_alarm_count)"
|
||||
system_after="$(get_system_alarm_count)"
|
||||
info "Plugin alarms after boot (app never opened): $after_count (expected: 1)"
|
||||
info "System/other alarms: $system_after (for context)"
|
||||
|
||||
if [[ "$after_count" -gt 0 && "$rescheduled" -gt 0 ]]; then
|
||||
ok "TEST 4 PASSED: Boot recovery occurred silently and alarms were recreated (rescheduled=$rescheduled) without app launch."
|
||||
|
||||
Reference in New Issue
Block a user