Browse Source

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
master
Matthew Raymer 1 week ago
parent
commit
10469a084e
  1. 60
      android/plugin/src/main/java/com/timesafari/dailynotification/BootReceiver.java
  2. 74
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  3. 66
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationReceiver.java
  4. 297
      android/plugin/src/main/java/com/timesafari/dailynotification/RecoveryManager.java

60
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<NotificationContent> 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);

74
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<NotificationContent> 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<NotificationContent> 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());
}
}

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

297
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<NotificationContent> 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<NotificationContent> 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<NotificationContent> 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);
}
}
}
Loading…
Cancel
Save