From fc2f64bae36100634e3b0687381d03c3492b08f4 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Mon, 1 Dec 2025 10:09:54 +0000 Subject: [PATCH] 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. --- .../dailynotification/BootReceiver.kt | 8 +- .../DailyNotificationPlugin.kt | 44 ++- .../DailyNotificationReceiver.java | 123 ++++++-- .../DailyNotificationScheduler.java | 5 + .../DailyNotificationWorker.java | 177 +++++++---- .../dailynotification/NotifyReceiver.kt | 222 ++++++++++++-- .../dailynotification/ReactivationManager.kt | 26 +- lib/bin/main/org/example/Library.class | Bin 0 -> 354 bytes lib/bin/test/org/example/LibraryTest.class | Bin 0 -> 713 bytes test-apps/android-test-app/alarm-test-lib.sh | 87 +++++- test-apps/android-test-app/test-phase1.sh | 284 +++++++++++++++--- test-apps/android-test-app/test-phase2.sh | 46 ++- test-apps/android-test-app/test-phase3.sh | 44 ++- 13 files changed, 880 insertions(+), 186 deletions(-) create mode 100644 lib/bin/main/org/example/Library.class create mode 100644 lib/bin/test/org/example/LibraryTest.class diff --git a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt index 545a339..3bfeaf2 100644 --- a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt @@ -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}") } } diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt index 849c84a..697e8df 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -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) diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java index e548de8..c1d8cbe 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java @@ -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 * diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java index abdfe7a..a8ea455 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java @@ -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 diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java index ad9ba07..0d3e3e1 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java @@ -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( diff --git a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt index fa8267e..740fb01 100644 --- a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt @@ -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 diff --git a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt index fd626d1..3bb3993 100644 --- a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt +++ b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt @@ -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 { diff --git a/lib/bin/main/org/example/Library.class b/lib/bin/main/org/example/Library.class new file mode 100644 index 0000000000000000000000000000000000000000..66de899e76fbaa26678528115d412d98464a1d19 GIT binary patch literal 354 zcmZusOHRWu6r7i)O$mX15C>of2;l>OkSZir0T!Sx*g5G_>ne$nnl@}KK_gl=XQa;@l-bTczKkuJA78`zw&os(QUwU9%VDdzUI zHcmKYG-JC3cAm4+c%CnnlQtGh75l<^0a}E;TkZ|fMAe)98rnD@G&d9!WuH~hA-voO zM%m2d=_Im+%;?au&chH7@hCu-5cw-NHTAPh+Cp~?kMV>Mio&z1vVC-x+zjE=S*52v z-}w4C<-9L?!hPnd2>Z9SlG_-bn%s`%N9AYH_-DdzJr@zUCVmes>wpb&i&>%R8Ud;MIeU+2_%%pJ;x)#$NK~3in#~zvICT gwO&ZrL4{dOrg?gM_1BtSLC8{{GMDg-qa{553mQMEo&W#< literal 0 HcmV?d00001 diff --git a/test-apps/android-test-app/alarm-test-lib.sh b/test-apps/android-test-app/alarm-test-lib.sh index 0e4c0d8..cbd8ed8 100644 --- a/test-apps/android-test-app/alarm-test-lib.sh +++ b/test-apps/android-test-app/alarm-test-lib.sh @@ -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 diff --git a/test-apps/android-test-app/test-phase1.sh b/test-apps/android-test-app/test-phase1.sh index e1e5d34..1a810ed 100755 --- a/test-apps/android-test-app/test-phase1.sh +++ b/test-apps/android-test-app/test-phase1.sh @@ -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=