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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
@PluginMethod
|
||||
public void getRecoveryStats(PluginCall call) {
|
||||
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) {
|
||||
try {
|
||||
Log.i(TAG, "Performing notification recovery...");
|
||||
ensureStorageInitialized();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user