diff --git a/.eslintrc.json b/.eslintrc.json index 4fdc0a3..5c22997 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,5 +15,13 @@ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "no-console": ["warn", { "allow": ["warn", "error"] }] - } + }, + "overrides": [ + { + "files": ["test-apps/daily-notification-test/src/lib/logger.ts"], + "rules": { + "no-console": "off" + } + } + ] } \ No newline at end of file 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 78578c2..f08de42 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetchWorker.java @@ -262,32 +262,55 @@ public class DailyNotificationFetchWorker extends Worker { java.util.List existingNotifications = storage.getAllNotifications(); long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts + // Track scheduled times in current batch to prevent within-batch duplicates + java.util.Set batchScheduledTimes = new java.util.HashSet<>(); + // 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; + // First check within current batch (prevents duplicates in same fetch) + long scheduledTime = content.getScheduledTime(); + boolean duplicateInBatch = false; + for (Long batchTime : batchScheduledTimes) { + if (Math.abs(batchTime - scheduledTime) <= toleranceMs) { + Log.w(TAG, "PR2: DUPLICATE_SKIP_BATCH id=" + content.getId() + + " scheduled_time=" + scheduledTime + + " time_diff_ms=" + Math.abs(batchTime - scheduledTime)); + duplicateInBatch = true; + skippedCount++; + break; + } + } + + if (duplicateInBatch) { + continue; + } + + // Then check against existing notifications in storage + boolean duplicateInStorage = false; for (NotificationContent existing : existingNotifications) { - if (Math.abs(existing.getScheduledTime() - content.getScheduledTime()) <= toleranceMs) { - Log.w(TAG, "PR2: DUPLICATE_SKIP id=" + content.getId() + + if (Math.abs(existing.getScheduledTime() - scheduledTime) <= toleranceMs) { + Log.w(TAG, "PR2: DUPLICATE_SKIP_STORAGE id=" + content.getId() + " existing_id=" + existing.getId() + - " scheduled_time=" + content.getScheduledTime() + - " time_diff_ms=" + Math.abs(existing.getScheduledTime() - content.getScheduledTime())); - duplicateFound = true; + " scheduled_time=" + scheduledTime + + " time_diff_ms=" + Math.abs(existing.getScheduledTime() - scheduledTime)); + duplicateInStorage = true; skippedCount++; break; } } - if (duplicateFound) { + if (duplicateInStorage) { // Skip this notification - one already exists for this time - // Ensures one prefetch โ†’ one notification pairing continue; } + // Mark this scheduledTime as processed in current batch + batchScheduledTimes.add(scheduledTime); + // Save content to storage storage.saveNotificationContent(content); diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java index ff10972..cc86234 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java @@ -71,6 +71,9 @@ public class DailyNotificationStorage { loadNotificationsFromStorage(); cleanupOldNotifications(); + // Remove duplicates on startup and cancel their alarms/workers + java.util.List removedIds = deduplicateNotifications(); + cancelRemovedNotifications(removedIds); } /** @@ -484,6 +487,99 @@ public class DailyNotificationStorage { getLastFetchTime()); } + /** + * Remove duplicate notifications (same scheduledTime within tolerance) + * + * Keeps the most recently created notification for each scheduledTime, + * removes older duplicates to prevent accumulation. + * + * @return List of notification IDs that were removed (for cancellation of alarms/workers) + */ + public java.util.List deduplicateNotifications() { + try { + long toleranceMs = 60 * 1000; // 1 minute tolerance + java.util.Map scheduledTimeMap = new java.util.HashMap<>(); + java.util.List idsToRemove = new java.util.ArrayList<>(); + + synchronized (notificationList) { + // First pass: find all duplicates, keep the one with latest fetchedAt + for (NotificationContent notification : notificationList) { + long scheduledTime = notification.getScheduledTime(); + boolean foundMatch = false; + + for (java.util.Map.Entry entry : scheduledTimeMap.entrySet()) { + if (Math.abs(entry.getKey() - scheduledTime) <= toleranceMs) { + // Found a duplicate - keep the one with latest fetchedAt + if (notification.getFetchedAt() > entry.getValue().getFetchedAt()) { + idsToRemove.add(entry.getValue().getId()); + entry.setValue(notification); + } else { + idsToRemove.add(notification.getId()); + } + foundMatch = true; + break; + } + } + + if (!foundMatch) { + scheduledTimeMap.put(scheduledTime, notification); + } + } + + // Remove duplicates + if (!idsToRemove.isEmpty()) { + notificationList.removeIf(n -> idsToRemove.contains(n.getId())); + for (String id : idsToRemove) { + notificationCache.remove(id); + } + saveNotificationsToStorage(); + Log.i(TAG, "DN|DEDUPE_CLEANUP removed=" + idsToRemove.size() + " duplicates"); + } + } + + return idsToRemove; + } catch (Exception e) { + Log.e(TAG, "Error during deduplication", e); + return new java.util.ArrayList<>(); + } + } + + /** + * Cancel alarms and workers for removed notification IDs + * + * This ensures that when notifications are removed (e.g., during deduplication), + * their associated alarms and WorkManager workers are also cancelled to prevent + * zombie workers trying to display non-existent notifications. + * + * @param removedIds List of notification IDs that were removed + */ + private void cancelRemovedNotifications(java.util.List removedIds) { + if (removedIds == null || removedIds.isEmpty()) { + return; + } + + try { + // Cancel alarms for removed notifications + DailyNotificationScheduler scheduler = new DailyNotificationScheduler( + context, + (android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE) + ); + + for (String id : removedIds) { + scheduler.cancelNotification(id); + } + + // Note: WorkManager workers can't be cancelled by notification ID directly + // Workers will handle missing content gracefully by returning Result.success() + // (see DailyNotificationWorker.handleDisplayNotification - it returns success for missing content) + // This prevents retry loops for notifications removed during deduplication + + Log.i(TAG, "DN|DEDUPE_CLEANUP cancelled alarms for " + removedIds.size() + " removed notifications"); + } catch (Exception e) { + Log.e(TAG, "DN|DEDUPE_CLEANUP_ERR failed to cancel alarms/workers", e); + } + } + /** * Enforce storage limits and retention policy * diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java index 6151c96..2051b70 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java @@ -131,8 +131,10 @@ public class DailyNotificationWorker extends Worker { NotificationContent content = getContentFromRoomOrLegacy(notificationId); if (content == null) { - Log.w(TAG, "DN|DISPLAY_ERR content_not_found id=" + notificationId); - return Result.failure(); + // Content not found - likely removed during deduplication or cleanup + // Return success instead of failure to prevent retries for intentionally removed notifications + Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)"); + return Result.success(); // Success prevents retry loops for removed notifications } // Check if notification is ready to display diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java b/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java index dd6d67d..a4d6ab1 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java @@ -298,9 +298,52 @@ public final class TimeSafariIntegrationManager { // 4) Convert bundle to NotificationContent[] and save/schedule List contents = convertBundleToNotificationContent(bundle); + // Get existing notifications for duplicate checking + java.util.List existingNotifications = storage.getAllNotifications(); + long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts + java.util.Set batchScheduledTimes = new java.util.HashSet<>(); + int scheduledCount = 0; + int skippedCount = 0; for (NotificationContent content : contents) { try { + // Check for duplicates within current batch + long scheduledTime = content.getScheduledTime(); + boolean duplicateInBatch = false; + for (Long batchTime : batchScheduledTimes) { + if (Math.abs(batchTime - scheduledTime) <= toleranceMs) { + logger.w("TS: DUPLICATE_SKIP_BATCH id=" + content.getId() + + " scheduled_time=" + scheduledTime); + duplicateInBatch = true; + skippedCount++; + break; + } + } + + if (duplicateInBatch) { + continue; + } + + // Check for duplicates in existing storage + boolean duplicateInStorage = false; + for (NotificationContent existing : existingNotifications) { + if (Math.abs(existing.getScheduledTime() - scheduledTime) <= toleranceMs) { + logger.w("TS: DUPLICATE_SKIP_STORAGE id=" + content.getId() + + " existing_id=" + existing.getId() + + " scheduled_time=" + scheduledTime); + duplicateInStorage = true; + skippedCount++; + break; + } + } + + if (duplicateInStorage) { + continue; + } + + // Mark this scheduledTime as processed + batchScheduledTimes.add(scheduledTime); + // Save content first storage.saveNotificationContent(content); // TTL validation happens inside scheduler.scheduleNotification() @@ -312,8 +355,9 @@ public final class TimeSafariIntegrationManager { logger.w("TS: schedule/save failed for id=" + content.getId() + " " + perItem.getMessage()); } } - - logger.i("TS: fetchAndScheduleFromServer done; scheduled=" + scheduledCount + "/" + contents.size()); + + logger.i("TS: fetchAndScheduleFromServer done; scheduled=" + scheduledCount + "/" + contents.size() + + (skippedCount > 0 ? ", " + skippedCount + " duplicates skipped" : "")); } catch (Exception ex) { logger.e("TS: fetchAndScheduleFromServer error", ex); diff --git a/test-apps/daily-notification-test/eslint.config.ts b/test-apps/daily-notification-test/eslint.config.ts index 2460dd6..b9439c5 100644 --- a/test-apps/daily-notification-test/eslint.config.ts +++ b/test-apps/daily-notification-test/eslint.config.ts @@ -26,6 +26,14 @@ export default defineConfigWithVueTs( '**/.gradle/**' ]), + // Whitelist console usage in logger utility (single source of truth) + { + files: ['src/lib/logger.ts'], + rules: { + 'no-console': 'off', + }, + }, + pluginVue.configs['flat/essential'], vueTsConfigs.recommended, ) diff --git a/test-apps/daily-notification-test/package-lock.json b/test-apps/daily-notification-test/package-lock.json index 88c8df1..575d83e 100644 --- a/test-apps/daily-notification-test/package-lock.json +++ b/test-apps/daily-notification-test/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "daily-notification-test", "version": "0.0.0", + "hasInstallScript": true, "dependencies": { "@capacitor/android": "^6.2.1", "@capacitor/cli": "^6.2.1", @@ -116,6 +117,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -415,6 +417,7 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.28.4" }, @@ -633,6 +636,7 @@ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", "integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -2064,6 +2068,7 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -2662,6 +2667,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2910,6 +2916,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3372,6 +3379,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3433,6 +3441,7 @@ "integrity": "sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -4292,6 +4301,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5720,6 +5730,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5797,6 +5808,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6030,6 +6042,7 @@ "integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6300,6 +6313,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6319,6 +6333,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", diff --git a/test-apps/daily-notification-test/src/App.vue b/test-apps/daily-notification-test/src/App.vue index f82f625..11c4b5b 100644 --- a/test-apps/daily-notification-test/src/App.vue +++ b/test-apps/daily-notification-test/src/App.vue @@ -36,12 +36,76 @@ diff --git a/test-apps/daily-notification-test/src/views/UserZeroView.vue b/test-apps/daily-notification-test/src/views/UserZeroView.vue index 545a926..2ab6e96 100644 --- a/test-apps/daily-notification-test/src/views/UserZeroView.vue +++ b/test-apps/daily-notification-test/src/views/UserZeroView.vue @@ -172,7 +172,7 @@ async function testJWTGeneration() { // Generate test JWT // Generate a fresh JWT token - apiClient.refreshToken() + await apiClient.refreshToken() const jwt = apiClient.getJWT() // Get the JWT from the client console.log('โœ… JWT generation successful')