Browse Source

fix(worker): prevent duplicate notifications from prefetch

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.
master
Matthew Raymer 2 days ago
parent
commit
59cd975c24
  1. 31
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java
  2. 97
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java

31
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<NotificationContent> 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) {

97
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)
*
* <p>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.</p>
*
* <p><b>Usage Flow:</b></p>
* <ol>
* <li>Host app registers native fetcher in {@code Application.onCreate()}</li>
* <li>TypeScript calls this method with API credentials</li>
* <li>Plugin validates parameters and calls {@code nativeFetcher.configure()}</li>
* <li>Native fetcher stores configuration for use in {@code fetchContent()}</li>
* </ol>
*
* <p><b>When to call:</b></p>
* <ul>
* <li>After app startup, once API credentials are available</li>
* <li>After user login/authentication, when activeDid changes</li>
* <li>When API server URL changes (e.g., switching between dev/staging/prod)</li>
* </ul>
*
* <p><b>Error Handling:</b></p>
* <ul>
* <li>Rejects if required parameters are missing</li>
* <li>Rejects if no native fetcher is registered</li>
* <li>Returns error if native fetcher's {@code configure()} throws exception</li>
* </ul>
*
* <p><b>Example TypeScript Usage:</b></p>
* <pre>{@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'
* });
* }</pre>
*
* @param call Plugin call containing configuration parameters:
* <ul>
* <li>{@code apiBaseUrl} (required): Base URL for API server.
* Android emulator: "http://10.0.2.2:3000" (maps to host localhost:3000).
* iOS simulator: "http://localhost:3000".
* Production: "https://api.timesafari.com"</li>
* <li>{@code activeDid} (required): Active DID for authentication.
* Format: "did:ethr:0x..."</li>
* <li>{@code jwtSecret} (required): JWT secret for signing tokens.
* Keep secure in production.</li>
* </ul>
*
* @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();

Loading…
Cancel
Save