Browse Source
			
			
			
			
				
		- Add deduplication system to prevent double-firing of notifications * Check existing notifications within 1-minute tolerance before scheduling * Prevents duplicate notifications from receiver double-firing on some OEMs * Structured logging: DN|RESCHEDULE_DUPLICATE, DN|SCHEDULE_DUPLICATE - Implement WorkManager doze fallback system for deep doze scenarios * DozeFallbackWorker runs 30 minutes before notification time * Re-arms exact alarms if they get pruned during deep doze mode * Battery-friendly constraints with no network requirement * Structured logging: DN|DOZE_FALLBACK_SCHEDULED, DN|DOZE_FALLBACK_REARM_OK - Add JIT soft re-fetch for borderline age content * SoftRefetchWorker prefetches fresh content when content is 80% of TTL * Runs 2 hours before tomorrow's notification for proactive freshness * Asynchronous background processing with network constraints * Structured logging: DN|JIT_BORDERLINE, DN|SOFT_REFETCH_SCHEDULED - Enhance DailyNotificationWorker with comprehensive resilience features * Ultra-lightweight receiver with WorkManager handoff * DST-safe scheduling with ZonedDateTime calculations * Storage capping and retention policy (100 entries, 14-day retention) * Performance monitoring with StrictMode and Trace markers - Add comprehensive status checking API * NotificationStatusChecker provides unified permission/channel status * Channel management with deep links to settings * Exact alarm permission validation and guidance All P1 features tested and working under system stress conditions. Notification system now production-ready with full resilience suite.master
				 4 changed files with 492 additions and 2 deletions
			
			
		@ -0,0 +1,173 @@ | 
				
			|||||
 | 
					/** | 
				
			||||
 | 
					 * DozeFallbackWorker.java | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * WorkManager worker for handling deep doze fallback scenarios | 
				
			||||
 | 
					 * Re-arms exact alarms if they get pruned during deep doze mode | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * @author Matthew Raymer | 
				
			||||
 | 
					 * @version 1.0.0 | 
				
			||||
 | 
					 */ | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					package com.timesafari.dailynotification; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import android.app.AlarmManager; | 
				
			||||
 | 
					import android.content.Context; | 
				
			||||
 | 
					import android.os.Trace; | 
				
			||||
 | 
					import android.util.Log; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import androidx.annotation.NonNull; | 
				
			||||
 | 
					import androidx.work.Worker; | 
				
			||||
 | 
					import androidx.work.WorkerParameters; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import java.util.List; | 
				
			||||
 | 
					import java.util.concurrent.TimeUnit; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					/** | 
				
			||||
 | 
					 * WorkManager worker for doze fallback scenarios | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * This worker runs 30 minutes before scheduled notifications to check | 
				
			||||
 | 
					 * if exact alarms are still active and re-arm them if needed. | 
				
			||||
 | 
					 */ | 
				
			||||
 | 
					public class DozeFallbackWorker extends Worker { | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    private static final String TAG = "DozeFallbackWorker"; | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    public DozeFallbackWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { | 
				
			||||
 | 
					        super(context, workerParams); | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    @NonNull | 
				
			||||
 | 
					    @Override | 
				
			||||
 | 
					    public Result doWork() { | 
				
			||||
 | 
					        Trace.beginSection("DN:DozeFallback"); | 
				
			||||
 | 
					        try { | 
				
			||||
 | 
					            long scheduledTime = getInputData().getLong("scheduled_time", -1); | 
				
			||||
 | 
					            String action = getInputData().getString("action"); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (scheduledTime == -1 || !"doze_fallback".equals(action)) { | 
				
			||||
 | 
					                Log.e(TAG, "DN|DOZE_FALLBACK_ERR invalid_input_data"); | 
				
			||||
 | 
					                return Result.failure(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            Log.d(TAG, "DN|DOZE_FALLBACK_START scheduled_time=" + scheduledTime); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Check if we're within 30 minutes of the scheduled time
 | 
				
			||||
 | 
					            long currentTime = System.currentTimeMillis(); | 
				
			||||
 | 
					            long timeUntilNotification = scheduledTime - currentTime; | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (timeUntilNotification < 0) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|DOZE_FALLBACK_SKIP notification_already_past"); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (timeUntilNotification > TimeUnit.MINUTES.toMillis(30)) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|DOZE_FALLBACK_SKIP too_early time_until=" + (timeUntilNotification / 1000 / 60) + "min"); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Check if exact alarm is still scheduled
 | 
				
			||||
 | 
					            boolean alarmStillActive = checkExactAlarmStatus(scheduledTime); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (!alarmStillActive) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|DOZE_FALLBACK_REARM exact_alarm_missing scheduled_time=" + scheduledTime); | 
				
			||||
 | 
					                 | 
				
			||||
 | 
					                // Re-arm the exact alarm
 | 
				
			||||
 | 
					                boolean rearmed = rearmExactAlarm(scheduledTime); | 
				
			||||
 | 
					                 | 
				
			||||
 | 
					                if (rearmed) { | 
				
			||||
 | 
					                    Log.i(TAG, "DN|DOZE_FALLBACK_OK exact_alarm_rearmed"); | 
				
			||||
 | 
					                    return Result.success(); | 
				
			||||
 | 
					                } else { | 
				
			||||
 | 
					                    Log.e(TAG, "DN|DOZE_FALLBACK_ERR rearm_failed"); | 
				
			||||
 | 
					                    return Result.retry(); | 
				
			||||
 | 
					                } | 
				
			||||
 | 
					            } else { | 
				
			||||
 | 
					                Log.d(TAG, "DN|DOZE_FALLBACK_OK exact_alarm_active"); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					        } catch (Exception e) { | 
				
			||||
 | 
					            Log.e(TAG, "DN|DOZE_FALLBACK_ERR exception=" + e.getMessage(), e); | 
				
			||||
 | 
					            return Result.retry(); | 
				
			||||
 | 
					        } finally { | 
				
			||||
 | 
					            Trace.endSection(); | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    /** | 
				
			||||
 | 
					     * Check if exact alarm is still active for the scheduled time | 
				
			||||
 | 
					     *  | 
				
			||||
 | 
					     * @param scheduledTime The scheduled notification time | 
				
			||||
 | 
					     * @return true if alarm is still active, false otherwise | 
				
			||||
 | 
					     */ | 
				
			||||
 | 
					    private boolean checkExactAlarmStatus(long scheduledTime) { | 
				
			||||
 | 
					        try { | 
				
			||||
 | 
					            // Get all notifications from storage
 | 
				
			||||
 | 
					            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); | 
				
			||||
 | 
					            List<NotificationContent> notifications = storage.getAllNotifications(); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Look for notification scheduled at the target time (within 1 minute tolerance)
 | 
				
			||||
 | 
					            long toleranceMs = 60 * 1000; // 1 minute tolerance
 | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            for (NotificationContent notification : notifications) { | 
				
			||||
 | 
					                if (Math.abs(notification.getScheduledTime() - scheduledTime) <= toleranceMs) { | 
				
			||||
 | 
					                    Log.d(TAG, "DN|DOZE_FALLBACK_CHECK found_notification id=" + notification.getId()); | 
				
			||||
 | 
					                    return true; | 
				
			||||
 | 
					                } | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            Log.w(TAG, "DN|DOZE_FALLBACK_CHECK no_notification_found scheduled_time=" + scheduledTime); | 
				
			||||
 | 
					            return false; | 
				
			||||
 | 
					             | 
				
			||||
 | 
					        } catch (Exception e) { | 
				
			||||
 | 
					            Log.e(TAG, "DN|DOZE_FALLBACK_CHECK_ERR err=" + e.getMessage(), e); | 
				
			||||
 | 
					            return false; | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    /** | 
				
			||||
 | 
					     * Re-arm the exact alarm for the scheduled time | 
				
			||||
 | 
					     *  | 
				
			||||
 | 
					     * @param scheduledTime The scheduled notification time | 
				
			||||
 | 
					     * @return true if re-arming succeeded, false otherwise | 
				
			||||
 | 
					     */ | 
				
			||||
 | 
					    private boolean rearmExactAlarm(long scheduledTime) { | 
				
			||||
 | 
					        try { | 
				
			||||
 | 
					            // Get all notifications from storage
 | 
				
			||||
 | 
					            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); | 
				
			||||
 | 
					            List<NotificationContent> notifications = storage.getAllNotifications(); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Find the notification scheduled at the target time
 | 
				
			||||
 | 
					            long toleranceMs = 60 * 1000; // 1 minute tolerance
 | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            for (NotificationContent notification : notifications) { | 
				
			||||
 | 
					                if (Math.abs(notification.getScheduledTime() - scheduledTime) <= toleranceMs) { | 
				
			||||
 | 
					                    Log.d(TAG, "DN|DOZE_FALLBACK_REARM found_target id=" + notification.getId()); | 
				
			||||
 | 
					                     | 
				
			||||
 | 
					                    // Re-schedule the notification
 | 
				
			||||
 | 
					                    DailyNotificationScheduler scheduler = new DailyNotificationScheduler( | 
				
			||||
 | 
					                        getApplicationContext(), | 
				
			||||
 | 
					                        (AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE) | 
				
			||||
 | 
					                    ); | 
				
			||||
 | 
					                     | 
				
			||||
 | 
					                    boolean scheduled = scheduler.scheduleNotification(notification); | 
				
			||||
 | 
					                     | 
				
			||||
 | 
					                    if (scheduled) { | 
				
			||||
 | 
					                        Log.i(TAG, "DN|DOZE_FALLBACK_REARM_OK id=" + notification.getId()); | 
				
			||||
 | 
					                        return true; | 
				
			||||
 | 
					                    } else { | 
				
			||||
 | 
					                        Log.e(TAG, "DN|DOZE_FALLBACK_REARM_FAIL id=" + notification.getId()); | 
				
			||||
 | 
					                        return false; | 
				
			||||
 | 
					                    } | 
				
			||||
 | 
					                } | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            Log.w(TAG, "DN|DOZE_FALLBACK_REARM_ERR no_target_found scheduled_time=" + scheduledTime); | 
				
			||||
 | 
					            return false; | 
				
			||||
 | 
					             | 
				
			||||
 | 
					        } catch (Exception e) { | 
				
			||||
 | 
					            Log.e(TAG, "DN|DOZE_FALLBACK_REARM_ERR exception=" + e.getMessage(), e); | 
				
			||||
 | 
					            return false; | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					} | 
				
			||||
@ -0,0 +1,153 @@ | 
				
			|||||
 | 
					/** | 
				
			||||
 | 
					 * SoftRefetchWorker.java | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * WorkManager worker for soft re-fetching notification content | 
				
			||||
 | 
					 * Prefetches fresh content for tomorrow's notifications asynchronously | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * @author Matthew Raymer | 
				
			||||
 | 
					 * @version 1.0.0 | 
				
			||||
 | 
					 */ | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					package com.timesafari.dailynotification; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import android.content.Context; | 
				
			||||
 | 
					import android.os.Trace; | 
				
			||||
 | 
					import android.util.Log; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import androidx.annotation.NonNull; | 
				
			||||
 | 
					import androidx.work.Worker; | 
				
			||||
 | 
					import androidx.work.WorkerParameters; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import java.util.List; | 
				
			||||
 | 
					import java.util.concurrent.TimeUnit; | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					/** | 
				
			||||
 | 
					 * WorkManager worker for soft re-fetching notification content | 
				
			||||
 | 
					 *  | 
				
			||||
 | 
					 * This worker runs 2 hours before tomorrow's notifications to prefetch | 
				
			||||
 | 
					 * fresh content, ensuring tomorrow's notifications are always fresh. | 
				
			||||
 | 
					 */ | 
				
			||||
 | 
					public class SoftRefetchWorker extends Worker { | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    private static final String TAG = "SoftRefetchWorker"; | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    public SoftRefetchWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { | 
				
			||||
 | 
					        super(context, workerParams); | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    @NonNull | 
				
			||||
 | 
					    @Override | 
				
			||||
 | 
					    public Result doWork() { | 
				
			||||
 | 
					        Trace.beginSection("DN:SoftRefetch"); | 
				
			||||
 | 
					        try { | 
				
			||||
 | 
					            long tomorrowScheduledTime = getInputData().getLong("tomorrow_scheduled_time", -1); | 
				
			||||
 | 
					            String action = getInputData().getString("action"); | 
				
			||||
 | 
					            String originalId = getInputData().getString("original_id"); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (tomorrowScheduledTime == -1 || !"soft_refetch".equals(action)) { | 
				
			||||
 | 
					                Log.e(TAG, "DN|SOFT_REFETCH_ERR invalid_input_data"); | 
				
			||||
 | 
					                return Result.failure(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            Log.d(TAG, "DN|SOFT_REFETCH_START original_id=" + originalId +  | 
				
			||||
 | 
					                      " tomorrow_time=" + tomorrowScheduledTime); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Check if we're within 2 hours of tomorrow's notification
 | 
				
			||||
 | 
					            long currentTime = System.currentTimeMillis(); | 
				
			||||
 | 
					            long timeUntilNotification = tomorrowScheduledTime - currentTime; | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (timeUntilNotification < 0) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|SOFT_REFETCH_SKIP notification_already_past"); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (timeUntilNotification > TimeUnit.HOURS.toMillis(2)) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|SOFT_REFETCH_SKIP too_early time_until=" + (timeUntilNotification / 1000 / 60) + "min"); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Fetch fresh content for tomorrow
 | 
				
			||||
 | 
					            boolean refetchSuccess = performSoftRefetch(tomorrowScheduledTime, originalId); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (refetchSuccess) { | 
				
			||||
 | 
					                Log.i(TAG, "DN|SOFT_REFETCH_OK original_id=" + originalId); | 
				
			||||
 | 
					                return Result.success(); | 
				
			||||
 | 
					            } else { | 
				
			||||
 | 
					                Log.e(TAG, "DN|SOFT_REFETCH_ERR original_id=" + originalId); | 
				
			||||
 | 
					                return Result.retry(); | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					        } catch (Exception e) { | 
				
			||||
 | 
					            Log.e(TAG, "DN|SOFT_REFETCH_ERR exception=" + e.getMessage(), e); | 
				
			||||
 | 
					            return Result.retry(); | 
				
			||||
 | 
					        } finally { | 
				
			||||
 | 
					            Trace.endSection(); | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					     | 
				
			||||
 | 
					    /** | 
				
			||||
 | 
					     * Perform soft re-fetch for tomorrow's notification content | 
				
			||||
 | 
					     *  | 
				
			||||
 | 
					     * @param tomorrowScheduledTime The scheduled time for tomorrow's notification | 
				
			||||
 | 
					     * @param originalId The original notification ID | 
				
			||||
 | 
					     * @return true if refetch succeeded, false otherwise | 
				
			||||
 | 
					     */ | 
				
			||||
 | 
					    private boolean performSoftRefetch(long tomorrowScheduledTime, String originalId) { | 
				
			||||
 | 
					        try { | 
				
			||||
 | 
					            // Get all notifications from storage
 | 
				
			||||
 | 
					            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); | 
				
			||||
 | 
					            List<NotificationContent> notifications = storage.getAllNotifications(); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Find tomorrow's notification (within 1 minute tolerance)
 | 
				
			||||
 | 
					            long toleranceMs = 60 * 1000; // 1 minute tolerance
 | 
				
			||||
 | 
					            NotificationContent tomorrowNotification = null; | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            for (NotificationContent notification : notifications) { | 
				
			||||
 | 
					                if (Math.abs(notification.getScheduledTime() - tomorrowScheduledTime) <= toleranceMs) { | 
				
			||||
 | 
					                    tomorrowNotification = notification; | 
				
			||||
 | 
					                    Log.d(TAG, "DN|SOFT_REFETCH_FOUND tomorrow_id=" + notification.getId()); | 
				
			||||
 | 
					                    break; | 
				
			||||
 | 
					                } | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (tomorrowNotification == null) { | 
				
			||||
 | 
					                Log.w(TAG, "DN|SOFT_REFETCH_ERR no_tomorrow_notification_found"); | 
				
			||||
 | 
					                return false; | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            // Fetch fresh content
 | 
				
			||||
 | 
					            DailyNotificationFetcher fetcher = new DailyNotificationFetcher( | 
				
			||||
 | 
					                getApplicationContext(),  | 
				
			||||
 | 
					                storage | 
				
			||||
 | 
					            ); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            NotificationContent freshContent = fetcher.fetchContentImmediately(); | 
				
			||||
 | 
					             | 
				
			||||
 | 
					            if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) { | 
				
			||||
 | 
					                Log.i(TAG, "DN|SOFT_REFETCH_FRESH_CONTENT tomorrow_id=" + tomorrowNotification.getId()); | 
				
			||||
 | 
					                 | 
				
			||||
 | 
					                // Update tomorrow's notification with fresh content
 | 
				
			||||
 | 
					                tomorrowNotification.setTitle(freshContent.getTitle()); | 
				
			||||
 | 
					                tomorrowNotification.setBody(freshContent.getBody()); | 
				
			||||
 | 
					                tomorrowNotification.setSound(freshContent.isSound()); | 
				
			||||
 | 
					                tomorrowNotification.setPriority(freshContent.getPriority()); | 
				
			||||
 | 
					                tomorrowNotification.setUrl(freshContent.getUrl()); | 
				
			||||
 | 
					                tomorrowNotification.setMediaUrl(freshContent.getMediaUrl()); | 
				
			||||
 | 
					                // Keep original scheduled time and ID
 | 
				
			||||
 | 
					                 | 
				
			||||
 | 
					                // Save updated content to storage
 | 
				
			||||
 | 
					                storage.saveNotificationContent(tomorrowNotification); | 
				
			||||
 | 
					                 | 
				
			||||
 | 
					                Log.i(TAG, "DN|SOFT_REFETCH_UPDATED tomorrow_id=" + tomorrowNotification.getId()); | 
				
			||||
 | 
					                return true; | 
				
			||||
 | 
					            } else { | 
				
			||||
 | 
					                Log.w(TAG, "DN|SOFT_REFETCH_FAIL no_fresh_content tomorrow_id=" + tomorrowNotification.getId()); | 
				
			||||
 | 
					                return false; | 
				
			||||
 | 
					            } | 
				
			||||
 | 
					             | 
				
			||||
 | 
					        } catch (Exception e) { | 
				
			||||
 | 
					            Log.e(TAG, "DN|SOFT_REFETCH_ERR exception=" + e.getMessage(), e); | 
				
			||||
 | 
					            return false; | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					} | 
				
			||||
					Loading…
					
					
				
		Reference in new issue