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:
Matthew Raymer
2025-10-31 12:51:49 +00:00
parent b0b89f4882
commit d4bb902cbe
14 changed files with 465 additions and 83 deletions

View File

@@ -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);

View File

@@ -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
*

View File

@@ -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

View File

@@ -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);