Browse Source
- 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 fallbacksmaster
4 changed files with 406 additions and 91 deletions
@ -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…
Reference in new issue