From 59cd975c24152b45fbefafef96a21bb5cb17618d Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 30 Oct 2025 10:02:54 +0000 Subject: [PATCH] fix(worker): prevent duplicate notifications from prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add duplicate checking in handleSuccessfulFetch() to ensure one prefetch creates at most one notification per scheduled time. This prevents prefetch from creating duplicate notifications when a manual notification already exists for the same time. - Check existing notifications before saving prefetch-created content - Skip notification creation if duplicate found (within 1 minute tolerance) - Add null check for fetcher in scheduleBackgroundFetch() with error logging - Log skipped duplicates for debugging Ensures one prefetch → one notification pairing and prevents duplicate notifications from firing at the same time. --- .../DailyNotificationFetchWorker.java | 31 +++++- .../DailyNotificationPlugin.java | 97 +++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java index 583cc8c..bf48f17 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java @@ -258,10 +258,36 @@ public class DailyNotificationFetchWorker extends Worker { // Update last fetch time storage.setLastFetchTime(System.currentTimeMillis()); - // Save all contents and schedule notifications + // Get existing notifications for duplicate checking (prevent prefetch from creating duplicate) + java.util.List existingNotifications = storage.getAllNotifications(); + long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts + + // Save all contents and schedule notifications (with duplicate checking) int scheduledCount = 0; + int skippedCount = 0; for (NotificationContent content : contents) { try { + // Check for duplicate notification at the same scheduled time + // This ensures prefetch doesn't create a duplicate if a manual notification already exists + boolean duplicateFound = false; + for (NotificationContent existing : existingNotifications) { + if (Math.abs(existing.getScheduledTime() - content.getScheduledTime()) <= toleranceMs) { + Log.w(TAG, "PR2: DUPLICATE_SKIP id=" + content.getId() + + " existing_id=" + existing.getId() + + " scheduled_time=" + content.getScheduledTime() + + " time_diff_ms=" + Math.abs(existing.getScheduledTime() - content.getScheduledTime())); + duplicateFound = true; + skippedCount++; + break; + } + } + + if (duplicateFound) { + // Skip this notification - one already exists for this time + // Ensures one prefetch → one notification pairing + continue; + } + // Save content to storage storage.saveNotificationContent(content); @@ -275,7 +301,8 @@ public class DailyNotificationFetchWorker extends Worker { } Log.i(TAG, "PR2: Successful fetch handling completed - " + scheduledCount + "/" + - contents.size() + " notifications scheduled"); + contents.size() + " notifications scheduled" + + (skippedCount > 0 ? ", " + skippedCount + " duplicates skipped" : "")); // TODO PR2: Record metrics (items_enqueued=scheduledCount) } catch (Exception e) { diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java index e1cb061..42e1a0b 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java @@ -145,6 +145,97 @@ public class DailyNotificationPlugin extends Plugin { return nativeFetcher; } + /** + * Configure native fetcher with API credentials (cross-platform method) + * + *

This plugin method receives configuration from TypeScript and passes it directly + * to the registered native fetcher implementation. This approach keeps the TypeScript + * interface cross-platform (works on Android, iOS, and web) without requiring + * platform-specific storage mechanisms.

+ * + *

Usage Flow:

+ *
    + *
  1. Host app registers native fetcher in {@code Application.onCreate()}
  2. + *
  3. TypeScript calls this method with API credentials
  4. + *
  5. Plugin validates parameters and calls {@code nativeFetcher.configure()}
  6. + *
  7. Native fetcher stores configuration for use in {@code fetchContent()}
  8. + *
+ * + *

When to call:

+ * + * + *

Error Handling:

+ * + * + *

Example TypeScript Usage:

+ *
{@code
+     * import { DailyNotification } from '@capacitor-community/daily-notification';
+     * 
+     * await DailyNotification.configureNativeFetcher({
+     *   apiBaseUrl: 'http://10.0.2.2:3000',
+     *   activeDid: 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F',
+     *   jwtSecret: 'test-jwt-secret-for-development'
+     * });
+     * }
+ * + * @param call Plugin call containing configuration parameters: + * + * + * @throws PluginException if configuration fails (rejected via call.reject()) + * + * @see NativeNotificationContentFetcher#configure(String, String, String) + */ + @PluginMethod + public void configureNativeFetcher(PluginCall call) { + try { + String apiBaseUrl = call.getString("apiBaseUrl"); + String activeDid = call.getString("activeDid"); + String jwtSecret = call.getString("jwtSecret"); + + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + call.reject("Missing required parameters: apiBaseUrl, activeDid, and jwtSecret are required"); + return; + } + + NativeNotificationContentFetcher fetcher = getNativeFetcher(); + if (fetcher == null) { + call.reject("No native fetcher registered. Register one in Application.onCreate() before configuring."); + return; + } + + Log.d(TAG, "SPI: Configuring native fetcher - apiBaseUrl: " + + apiBaseUrl.substring(0, Math.min(50, apiBaseUrl.length())) + + "... activeDid: " + activeDid.substring(0, Math.min(30, activeDid.length())) + "..."); + + // Call configure on the native fetcher (defaults to no-op if not implemented) + fetcher.configure(apiBaseUrl, activeDid, jwtSecret); + + Log.i(TAG, "SPI: Native fetcher configured successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "SPI: Error configuring native fetcher", e); + call.reject("Failed to configure native fetcher: " + e.getMessage()); + } + } + /** * Initialize the plugin and create notification channel */ @@ -962,6 +1053,12 @@ public class DailyNotificationPlugin extends Plugin { try { Log.i(TAG, "DN|SCHEDULE_FETCH_START time=" + scheduledTime + " current=" + System.currentTimeMillis()); + // Check if fetcher is initialized + if (fetcher == null) { + Log.e(TAG, "DN|SCHEDULE_FETCH_ERR fetcher is null - cannot schedule prefetch. Plugin may not be fully loaded."); + return; + } + // Schedule fetch 5 minutes before notification long fetchTime = scheduledTime - TimeUnit.MINUTES.toMillis(5); long currentTime = System.currentTimeMillis();