refactor(test-app): consolidate native fetcher config and fix ES module issues
- Move native fetcher configuration from HomeView.vue to App.vue mounted() hook - Single source of truth for configuration on app startup - Removed duplicate configuration logic from HomeView - Added diagnostic logging to trace configuration flow - Fix ES module compatibility issue with Capacitor CLI - Replace direct logger import with lazy async loading in test-user-zero.ts - Prevents 'exports is not defined' error when Capacitor CLI loads config - Update refreshToken() and setBaseUrl() methods to async for logger access - Add centralized logger utility (src/lib/logger.ts) - Single ESLint whitelist location for console usage - Structured logging with levels and emoji support - Updated router/index.ts and stores/app.ts to use logger - Enhance Android notification deduplication - Add within-batch duplicate detection in fetch workers - Improve storage deduplication with alarm cancellation - Cancel alarms for removed duplicate notifications - Update UserZeroView.vue to await async refreshToken() call Fixes: - npx cap sync android ES module error - Duplicate notification accumulation - Console statement lint warnings All changes maintain backward compatibility and improve debugging visibility.
This commit is contained in:
@@ -262,32 +262,55 @@ public class DailyNotificationFetchWorker extends Worker {
|
||||
java.util.List<NotificationContent> 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<Long> 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;
|
||||
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;
|
||||
// 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 (duplicateFound) {
|
||||
// Skip this notification - one already exists for this time
|
||||
// Ensures one prefetch → one notification pairing
|
||||
if (duplicateInBatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Then check against existing notifications in storage
|
||||
boolean duplicateInStorage = false;
|
||||
for (NotificationContent existing : existingNotifications) {
|
||||
if (Math.abs(existing.getScheduledTime() - scheduledTime) <= toleranceMs) {
|
||||
Log.w(TAG, "PR2: DUPLICATE_SKIP_STORAGE id=" + content.getId() +
|
||||
" existing_id=" + existing.getId() +
|
||||
" scheduled_time=" + scheduledTime +
|
||||
" time_diff_ms=" + Math.abs(existing.getScheduledTime() - scheduledTime));
|
||||
duplicateInStorage = true;
|
||||
skippedCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateInStorage) {
|
||||
// Skip this notification - one already exists for this time
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark this scheduledTime as processed in current batch
|
||||
batchScheduledTimes.add(scheduledTime);
|
||||
|
||||
// Save content to storage
|
||||
storage.saveNotificationContent(content);
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ public class DailyNotificationStorage {
|
||||
|
||||
loadNotificationsFromStorage();
|
||||
cleanupOldNotifications();
|
||||
// Remove duplicates on startup and cancel their alarms/workers
|
||||
java.util.List<String> 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<String> deduplicateNotifications() {
|
||||
try {
|
||||
long toleranceMs = 60 * 1000; // 1 minute tolerance
|
||||
java.util.Map<Long, NotificationContent> scheduledTimeMap = new java.util.HashMap<>();
|
||||
java.util.List<String> 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<Long, NotificationContent> 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<String> 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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -298,9 +298,52 @@ public final class TimeSafariIntegrationManager {
|
||||
// 4) Convert bundle to NotificationContent[] and save/schedule
|
||||
List<NotificationContent> contents = convertBundleToNotificationContent(bundle);
|
||||
|
||||
// Get existing notifications for duplicate checking
|
||||
java.util.List<NotificationContent> existingNotifications = storage.getAllNotifications();
|
||||
long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts
|
||||
java.util.Set<Long> 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);
|
||||
|
||||
Reference in New Issue
Block a user