From 10469a084e33cc67ef1152327e51d3ae7022fd0e Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 14 Oct 2025 07:24:35 +0000 Subject: [PATCH] feat(plugin): implement P0 production-grade improvements - P0 Priority 3: JIT freshness re-check (soft TTL) - Add performJITFreshnessCheck() in DailyNotificationReceiver - Check content staleness (6-hour threshold) before display - Attempt fresh content fetch with fallback to original - Preserve notification ID and scheduled time during refresh - P0 Priority 4: Boot & app-startup recovery coexistence - Create RecoveryManager singleton for centralized recovery - Implement idempotent recovery with atomic operations - Add 5-minute cooldown to prevent duplicate recovery - Track recovery state with SharedPreferences persistence - Update BootReceiver to use RecoveryManager - Update DailyNotificationPlugin startup recovery - Add getRecoveryStats() plugin method for debugging Benefits: - Notifications stay fresh with automatic content refresh - Recovery operations are safe to call multiple times - Boot and app startup recovery work together seamlessly - Comprehensive logging for debugging recovery issues - Production-ready error handling and fallbacks --- .../dailynotification/BootReceiver.java | 60 ++-- .../DailyNotificationPlugin.java | 74 ++--- .../DailyNotificationReceiver.java | 66 ++++ .../dailynotification/RecoveryManager.java | 297 ++++++++++++++++++ 4 files changed, 406 insertions(+), 91 deletions(-) create mode 100644 android/plugin/src/main/java/com/timesafari/dailynotification/RecoveryManager.java diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java b/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java index 483e538..70be78c 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java @@ -101,46 +101,21 @@ public class BootReceiver extends BroadcastReceiver { Log.i(TAG, "Device boot completed - restoring notifications"); try { - // Initialize storage to load saved notifications + // Initialize components for recovery DailyNotificationStorage storage = new DailyNotificationStorage(context); - - // Get all saved notifications - java.util.List notifications = storage.getAllNotifications(); - - if (notifications.isEmpty()) { - Log.i(TAG, "No notifications to recover"); - return; - } - - Log.i(TAG, "Found " + notifications.size() + " notifications to recover"); - - // Initialize scheduler for rescheduling android.app.AlarmManager alarmManager = (android.app.AlarmManager) context.getSystemService(android.content.Context.ALARM_SERVICE); DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager); - // Reschedule each notification - int recoveredCount = 0; - for (NotificationContent notification : notifications) { - try { - // Only reschedule future notifications - if (notification.getScheduledTime() > System.currentTimeMillis()) { - boolean scheduled = scheduler.scheduleNotification(notification); - if (scheduled) { - recoveredCount++; - Log.d(TAG, "Recovered notification: " + notification.getId()); - } else { - Log.w(TAG, "Failed to recover notification: " + notification.getId()); - } - } else { - Log.d(TAG, "Skipping past notification: " + notification.getId()); - } - } catch (Exception e) { - Log.e(TAG, "Error recovering notification: " + notification.getId(), e); - } - } + // Use centralized recovery manager for idempotent recovery + RecoveryManager recoveryManager = RecoveryManager.getInstance(context, storage, scheduler); + boolean recoveryPerformed = recoveryManager.performRecoveryIfNeeded("BOOT_COMPLETED"); - Log.i(TAG, "Notification recovery completed: " + recoveredCount + "/" + notifications.size() + " recovered"); + if (recoveryPerformed) { + Log.i(TAG, "Boot recovery completed successfully"); + } else { + Log.d(TAG, "Boot recovery skipped (not needed or already performed)"); + } } catch (Exception e) { Log.e(TAG, "Error during boot recovery", e); @@ -157,8 +132,21 @@ public class BootReceiver extends BroadcastReceiver { Log.i(TAG, "Package replaced - restoring notifications"); try { - // Use the same recovery logic as boot - handleBootCompleted(context); + // Initialize components for recovery + DailyNotificationStorage storage = new DailyNotificationStorage(context); + android.app.AlarmManager alarmManager = (android.app.AlarmManager) + context.getSystemService(android.content.Context.ALARM_SERVICE); + DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager); + + // Use centralized recovery manager for idempotent recovery + RecoveryManager recoveryManager = RecoveryManager.getInstance(context, storage, scheduler); + boolean recoveryPerformed = recoveryManager.performRecoveryIfNeeded("MY_PACKAGE_REPLACED"); + + if (recoveryPerformed) { + Log.i(TAG, "Package replacement recovery completed successfully"); + } else { + Log.d(TAG, "Package replacement recovery skipped (not needed or already performed)"); + } } catch (Exception e) { Log.e(TAG, "Error during package replacement recovery", 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 eaee7e2..98d0d45 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java @@ -888,24 +888,14 @@ public class DailyNotificationPlugin extends Plugin { // Ensure storage is initialized ensureStorageInitialized(); - // Check if we have saved notifications - java.util.List notifications = storage.getAllNotifications(); + // Use centralized recovery manager for idempotent recovery + RecoveryManager recoveryManager = RecoveryManager.getInstance(getContext(), storage, scheduler); + boolean recoveryPerformed = recoveryManager.performRecoveryIfNeeded("APP_STARTUP"); - if (notifications.isEmpty()) { - Log.d(TAG, "No notifications to recover"); - return; - } - - Log.i(TAG, "Found " + notifications.size() + " notifications to recover"); - - // Check if any alarms are currently scheduled - boolean hasScheduledAlarms = checkScheduledAlarms(); - - if (!hasScheduledAlarms) { - Log.i(TAG, "No scheduled alarms found - performing recovery"); - performRecovery(notifications); + if (recoveryPerformed) { + Log.i(TAG, "App startup recovery completed successfully"); } else { - Log.d(TAG, "Alarms already scheduled - no recovery needed"); + Log.d(TAG, "App startup recovery skipped (not needed or already performed)"); } } catch (Exception e) { @@ -914,50 +904,24 @@ public class DailyNotificationPlugin extends Plugin { } /** - * Check if any alarms are currently scheduled + * Get recovery statistics for debugging */ - private boolean checkScheduledAlarms() { - try { - // This is a simple check - in a real implementation, you'd check AlarmManager - // For now, we'll assume recovery is needed if we have saved notifications - return false; - } catch (Exception e) { - Log.e(TAG, "Error checking scheduled alarms", e); - return false; - } - } - - /** - * Perform recovery of scheduled notifications - */ - private void performRecovery(java.util.List notifications) { + @PluginMethod + public void getRecoveryStats(PluginCall call) { try { - Log.i(TAG, "Performing notification recovery..."); - - int recoveredCount = 0; - for (NotificationContent notification : notifications) { - try { - // Only reschedule future notifications - if (notification.getScheduledTime() > System.currentTimeMillis()) { - boolean scheduled = scheduler.scheduleNotification(notification); - if (scheduled) { - recoveredCount++; - Log.d(TAG, "Recovered notification: " + notification.getId()); - } else { - Log.w(TAG, "Failed to recover notification: " + notification.getId()); - } - } else { - Log.d(TAG, "Skipping past notification: " + notification.getId()); - } - } catch (Exception e) { - Log.e(TAG, "Error recovering notification: " + notification.getId(), e); - } - } + ensureStorageInitialized(); + + RecoveryManager recoveryManager = RecoveryManager.getInstance(getContext(), storage, scheduler); + String stats = recoveryManager.getRecoveryStats(); - Log.i(TAG, "Notification recovery completed: " + recoveredCount + "/" + notifications.size() + " recovered"); + JSObject result = new JSObject(); + result.put("stats", stats); + + call.resolve(result); } catch (Exception e) { - Log.e(TAG, "Error during notification recovery", e); + Log.e(TAG, "Error getting recovery stats", e); + call.reject("Error getting recovery stats: " + e.getMessage()); } } diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java index d2ad93f..0ac8e53 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java @@ -93,6 +93,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver { return; } + // JIT Freshness Re-check (Soft TTL) + content = performJITFreshnessCheck(context, content); + // Display the notification displayNotification(context, content); @@ -106,6 +109,69 @@ public class DailyNotificationReceiver extends BroadcastReceiver { } } + /** + * Perform JIT (Just-In-Time) freshness re-check for notification content + * + * This implements a soft TTL mechanism that attempts to refresh stale content + * just before displaying the notification. If the refresh fails or content + * is not stale, the original content is returned. + * + * @param context Application context + * @param content Original notification content + * @return Updated content if refresh succeeded, original content otherwise + */ + private NotificationContent performJITFreshnessCheck(Context context, NotificationContent content) { + try { + // Check if content is stale (older than 6 hours for JIT check) + long currentTime = System.currentTimeMillis(); + long age = currentTime - content.getFetchedAt(); + long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds + + if (age < staleThreshold) { + Log.d(TAG, "Content is fresh (age: " + (age / 1000 / 60) + " minutes), skipping JIT refresh"); + return content; + } + + Log.i(TAG, "Content is stale (age: " + (age / 1000 / 60) + " minutes), attempting JIT refresh"); + + // Attempt to fetch fresh content + DailyNotificationFetcher fetcher = new DailyNotificationFetcher(context, new DailyNotificationStorage(context)); + + // Attempt immediate fetch for fresh content + NotificationContent freshContent = fetcher.fetchContentImmediately(); + + if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) { + Log.i(TAG, "JIT refresh succeeded, using fresh content"); + + // Update the original content with fresh data while preserving the original ID and scheduled time + String originalId = content.getId(); + long originalScheduledTime = content.getScheduledTime(); + + content.setTitle(freshContent.getTitle()); + content.setBody(freshContent.getBody()); + content.setSound(freshContent.isSound()); + content.setPriority(freshContent.getPriority()); + content.setUrl(freshContent.getUrl()); + content.setMediaUrl(freshContent.getMediaUrl()); + content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time + // Note: fetchedAt remains unchanged to preserve original fetch time + + // Save updated content to storage + DailyNotificationStorage storage = new DailyNotificationStorage(context); + storage.saveNotificationContent(content); + + return content; + } else { + Log.w(TAG, "JIT refresh failed or returned empty content, using original content"); + return content; + } + + } catch (Exception e) { + Log.e(TAG, "Error during JIT freshness check", e); + return content; // Return original content on error + } + } + /** * Display the notification to the user * diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/RecoveryManager.java b/android/plugin/src/main/java/com/timesafari/dailynotification/RecoveryManager.java new file mode 100644 index 0000000..36aa08b --- /dev/null +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/RecoveryManager.java @@ -0,0 +1,297 @@ +/** + * RecoveryManager.java + * + * Centralized recovery manager for notification persistence + * Provides idempotent, cheap recovery operations for both boot and app startup scenarios + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.util.Log; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Centralized recovery manager for notification persistence + * + * This class provides idempotent recovery operations that can be safely called + * from multiple sources (boot receiver, app startup, etc.) without conflicts. + * It uses atomic operations and caching to ensure cheap, safe recovery. + */ +public class RecoveryManager { + + private static final String TAG = "RecoveryManager"; + private static final String RECOVERY_STATE_KEY = "recovery_last_performed"; + private static final String RECOVERY_COUNT_KEY = "recovery_count"; + + // Singleton instance + private static volatile RecoveryManager instance; + private static final Object lock = new Object(); + + // Recovery state tracking + private final AtomicBoolean recoveryInProgress = new AtomicBoolean(false); + private final AtomicBoolean lastRecoverySuccessful = new AtomicBoolean(false); + private long lastRecoveryTime = 0; + private int recoveryCount = 0; + + // Components + private final Context context; + private final DailyNotificationStorage storage; + private final DailyNotificationScheduler scheduler; + + /** + * Private constructor for singleton + */ + private RecoveryManager(Context context, DailyNotificationStorage storage, DailyNotificationScheduler scheduler) { + this.context = context; + this.storage = storage; + this.scheduler = scheduler; + + // Load recovery state from storage + loadRecoveryState(); + } + + /** + * Get singleton instance + */ + public static RecoveryManager getInstance(Context context, DailyNotificationStorage storage, DailyNotificationScheduler scheduler) { + if (instance == null) { + synchronized (lock) { + if (instance == null) { + instance = new RecoveryManager(context, storage, scheduler); + } + } + } + return instance; + } + + /** + * Perform idempotent recovery if needed + * + * This method is safe to call multiple times and from multiple sources. + * It will only perform recovery if: + * 1. No recovery is currently in progress + * 2. Recovery hasn't been performed recently (within 5 minutes) + * 3. There are notifications to recover + * 4. No alarms are currently scheduled + * + * @param source Source of the recovery request (for logging) + * @return true if recovery was performed, false if skipped + */ + public boolean performRecoveryIfNeeded(String source) { + try { + Log.d(TAG, "Recovery requested from: " + source); + + // Check if recovery is already in progress + if (!recoveryInProgress.compareAndSet(false, true)) { + Log.d(TAG, "Recovery already in progress, skipping"); + return false; + } + + try { + // Check if recovery was performed recently (within 5 minutes) + long currentTime = System.currentTimeMillis(); + long timeSinceLastRecovery = currentTime - lastRecoveryTime; + long recoveryCooldown = 5 * 60 * 1000; // 5 minutes + + if (timeSinceLastRecovery < recoveryCooldown && lastRecoverySuccessful.get()) { + Log.d(TAG, "Recovery performed recently (" + (timeSinceLastRecovery / 1000) + "s ago), skipping"); + return false; + } + + // Check if we have notifications to recover + List notifications = storage.getAllNotifications(); + if (notifications.isEmpty()) { + Log.d(TAG, "No notifications to recover"); + lastRecoverySuccessful.set(true); + lastRecoveryTime = currentTime; + saveRecoveryState(); + return false; + } + + Log.i(TAG, "Found " + notifications.size() + " notifications to recover from " + source); + + // Check if alarms are already scheduled (quick check) + if (hasScheduledAlarms()) { + Log.d(TAG, "Alarms already scheduled, skipping recovery"); + lastRecoverySuccessful.set(true); + lastRecoveryTime = currentTime; + saveRecoveryState(); + return false; + } + + // Perform the actual recovery + boolean success = performRecovery(notifications, source); + + // Update state + lastRecoverySuccessful.set(success); + lastRecoveryTime = currentTime; + recoveryCount++; + saveRecoveryState(); + + return success; + + } finally { + // Always release the lock + recoveryInProgress.set(false); + } + + } catch (Exception e) { + Log.e(TAG, "Error during recovery check from " + source, e); + recoveryInProgress.set(false); + return false; + } + } + + /** + * Perform the actual recovery operation + */ + private boolean performRecovery(List notifications, String source) { + try { + Log.i(TAG, "Performing notification recovery from " + source); + + int recoveredCount = 0; + int skippedCount = 0; + + for (NotificationContent notification : notifications) { + try { + // Only reschedule future notifications + if (notification.getScheduledTime() > System.currentTimeMillis()) { + boolean scheduled = scheduler.scheduleNotification(notification); + if (scheduled) { + recoveredCount++; + Log.d(TAG, "Recovered notification: " + notification.getId()); + } else { + Log.w(TAG, "Failed to recover notification: " + notification.getId()); + } + } else { + skippedCount++; + Log.d(TAG, "Skipping past notification: " + notification.getId()); + } + } catch (Exception e) { + Log.e(TAG, "Error recovering notification: " + notification.getId(), e); + } + } + + Log.i(TAG, "Recovery completed from " + source + ": " + recoveredCount + "/" + notifications.size() + " recovered, " + skippedCount + " skipped"); + + return recoveredCount > 0; + + } catch (Exception e) { + Log.e(TAG, "Error during notification recovery from " + source, e); + return false; + } + } + + /** + * Quick check if alarms are currently scheduled + * This is a lightweight check that doesn't require expensive operations + */ + private boolean hasScheduledAlarms() { + try { + // For now, we'll use a simple heuristic: + // If we have notifications and the last recovery was successful recently, + // assume alarms are scheduled + long currentTime = System.currentTimeMillis(); + long timeSinceLastRecovery = currentTime - lastRecoveryTime; + long recentThreshold = 10 * 60 * 1000; // 10 minutes + + if (lastRecoverySuccessful.get() && timeSinceLastRecovery < recentThreshold) { + Log.d(TAG, "Assuming alarms are scheduled based on recent successful recovery"); + return true; + } + + // TODO: Implement actual alarm checking using AlarmManager + // This would involve checking if any alarms with our package name are scheduled + return false; + + } catch (Exception e) { + Log.e(TAG, "Error checking scheduled alarms", e); + return false; + } + } + + /** + * Load recovery state from storage + */ + private void loadRecoveryState() { + try { + // Load from SharedPreferences or similar + android.content.SharedPreferences prefs = context.getSharedPreferences("recovery_state", Context.MODE_PRIVATE); + lastRecoveryTime = prefs.getLong(RECOVERY_STATE_KEY, 0); + recoveryCount = prefs.getInt(RECOVERY_COUNT_KEY, 0); + lastRecoverySuccessful.set(prefs.getBoolean("recovery_successful", false)); + + Log.d(TAG, "Loaded recovery state: lastTime=" + lastRecoveryTime + ", count=" + recoveryCount + ", successful=" + lastRecoverySuccessful.get()); + + } catch (Exception e) { + Log.e(TAG, "Error loading recovery state", e); + } + } + + /** + * Save recovery state to storage + */ + private void saveRecoveryState() { + try { + android.content.SharedPreferences prefs = context.getSharedPreferences("recovery_state", Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(RECOVERY_STATE_KEY, lastRecoveryTime); + editor.putInt(RECOVERY_COUNT_KEY, recoveryCount); + editor.putBoolean("recovery_successful", lastRecoverySuccessful.get()); + editor.apply(); + + Log.d(TAG, "Saved recovery state: lastTime=" + lastRecoveryTime + ", count=" + recoveryCount + ", successful=" + lastRecoverySuccessful.get()); + + } catch (Exception e) { + Log.e(TAG, "Error saving recovery state", e); + } + } + + /** + * Get recovery statistics + */ + public String getRecoveryStats() { + return "RecoveryManager stats: count=" + recoveryCount + + ", lastTime=" + lastRecoveryTime + + ", successful=" + lastRecoverySuccessful.get() + + ", inProgress=" + recoveryInProgress.get(); + } + + /** + * Force recovery (bypass cooldown and checks) + * Use with caution - only for testing or emergency situations + */ + public boolean forceRecovery(String source) { + Log.w(TAG, "Force recovery requested from: " + source); + + if (!recoveryInProgress.compareAndSet(false, true)) { + Log.w(TAG, "Recovery already in progress, cannot force"); + return false; + } + + try { + List notifications = storage.getAllNotifications(); + if (notifications.isEmpty()) { + Log.w(TAG, "No notifications to recover in force recovery"); + return false; + } + + boolean success = performRecovery(notifications, source + " (FORCED)"); + + lastRecoverySuccessful.set(success); + lastRecoveryTime = System.currentTimeMillis(); + recoveryCount++; + saveRecoveryState(); + + return success; + + } finally { + recoveryInProgress.set(false); + } + } +}