/** * DailyNotificationScheduler.java * * Handles scheduling and timing of daily notifications * Implements exact and inexact alarm scheduling with battery optimization handling * * @author Matthew Raymer * @version 1.0.0 */ package com.timesafari.dailynotification; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; import android.util.Log; import java.util.Calendar; import java.util.concurrent.ConcurrentHashMap; /** * Manages scheduling of daily notifications using AlarmManager * * This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline. * It supports both exact and inexact alarms based on system permissions and battery optimization. */ public class DailyNotificationScheduler { private static final String TAG = "DailyNotificationScheduler"; private static final String ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION"; private static final String EXTRA_NOTIFICATION_ID = "notification_id"; private final Context context; private final AlarmManager alarmManager; private final ConcurrentHashMap scheduledAlarms; // Track scheduled times to prevent duplicate alarms for same time // Maps scheduledTime (ms) -> notificationId that has alarm scheduled private final ConcurrentHashMap scheduledTimeToId; // PendingIntent management private PendingIntentManager pendingIntentManager; // TTL enforcement private DailyNotificationTTLEnforcer ttlEnforcer; // Exact alarm management private DailyNotificationExactAlarmManager exactAlarmManager; /** * Constructor * * @param context Application context * @param alarmManager System AlarmManager service */ public DailyNotificationScheduler(Context context, AlarmManager alarmManager) { this.context = context; this.alarmManager = alarmManager; this.scheduledAlarms = new ConcurrentHashMap<>(); this.scheduledTimeToId = new ConcurrentHashMap<>(); this.pendingIntentManager = new PendingIntentManager(context); Log.d(TAG, "DailyNotificationScheduler initialized with PendingIntentManager"); } /** * Set TTL enforcer for freshness validation * * @param ttlEnforcer TTL enforcement instance */ public void setTTLEnforcer(DailyNotificationTTLEnforcer ttlEnforcer) { this.ttlEnforcer = ttlEnforcer; Log.d(TAG, "TTL enforcer set for freshness validation"); } /** * Get alarm scheduling status * * @return AlarmStatus with detailed information */ public PendingIntentManager.AlarmStatus getAlarmStatus() { return pendingIntentManager.getAlarmStatus(); } /** * Set exact alarm manager for alarm scheduling * * @param exactAlarmManager Exact alarm manager instance */ public void setExactAlarmManager(DailyNotificationExactAlarmManager exactAlarmManager) { this.exactAlarmManager = exactAlarmManager; Log.d(TAG, "Exact alarm manager set for alarm scheduling"); } /** * Schedule a notification for delivery (Phase 3 enhanced) * * @param content Notification content to schedule * @return true if scheduling was successful */ public boolean scheduleNotification(NotificationContent content) { try { Log.d(TAG, "Phase 3: Scheduling notification: " + content.getId()); // Phase 3: TimeSafari coordination before scheduling if (!shouldScheduleWithTimeSafariCoordination(content)) { Log.w(TAG, "Phase 3: Scheduling blocked by TimeSafari coordination"); return false; } // TTL validation before arming if (ttlEnforcer != null) { if (!ttlEnforcer.validateBeforeArming(content)) { Log.w(TAG, "Skipping notification due to TTL violation: " + content.getId()); return false; } } else { Log.w(TAG, "TTL enforcer not set, proceeding without freshness validation"); } // Cancel any existing alarm for this notification ID to prevent duplicates // This ensures only one alarm exists for this notification at a time Log.d(TAG, "Phase 3: Cancelling existing alarm for notification: " + content.getId()); cancelNotification(content.getId()); // Get scheduled time and check for duplicate alarms at same time long triggerTime = content.getScheduledTime(); long toleranceMs = 60 * 1000; // 1 minute tolerance for DST/clock adjustments // Cancel any existing alarm for the same scheduled time (within tolerance) // This prevents multiple notifications scheduled for same time from creating duplicate alarms // Check all scheduled times to find any within tolerance java.util.List duplicateIds = new java.util.ArrayList<>(); for (java.util.Map.Entry entry : scheduledTimeToId.entrySet()) { Long scheduledTime = entry.getKey(); String existingId = entry.getValue(); // Skip if it's the same notification ID or time difference is too large if (existingId.equals(content.getId()) || Math.abs(scheduledTime - triggerTime) > toleranceMs) { continue; } // Found an alarm scheduled for a time very close to this one duplicateIds.add(existingId); } // Cancel any duplicate alarms found for (String duplicateId : duplicateIds) { Log.w(TAG, "Phase 3: Cancelling duplicate alarm for time " + formatTime(triggerTime) + " (existing ID: " + duplicateId + ", new ID: " + content.getId() + ")"); cancelNotification(duplicateId); } // Create intent for the notification Intent intent = new Intent(context, DailyNotificationReceiver.class); intent.setAction(ACTION_NOTIFICATION); intent.putExtra(EXTRA_NOTIFICATION_ID, content.getId()); // Check if this is a static reminder if (content.getId().startsWith("reminder_") || content.getId().contains("_reminder")) { intent.putExtra("is_static_reminder", true); intent.putExtra("reminder_id", content.getId()); intent.putExtra("title", content.getTitle()); intent.putExtra("body", content.getBody()); intent.putExtra("sound", content.isSound()); intent.putExtra("vibration", true); // Default to true for reminders intent.putExtra("priority", content.getPriority()); } // Create pending intent with unique request code using PendingIntentManager int requestCode = content.getId().hashCode(); PendingIntent pendingIntent = pendingIntentManager.createBroadcastPendingIntent(intent, requestCode); // Store the pending intent scheduledAlarms.put(content.getId(), pendingIntent); // Track scheduled time to notification ID mapping scheduledTimeToId.put(triggerTime, content.getId()); // Schedule the alarm boolean scheduled = scheduleAlarm(pendingIntent, triggerTime); if (scheduled) { Log.i(TAG, "Notification scheduled successfully for " + formatTime(triggerTime)); return true; } else { Log.e(TAG, "Failed to schedule notification"); scheduledAlarms.remove(content.getId()); return false; } } catch (Exception e) { Log.e(TAG, "Error scheduling notification", e); return false; } } /** * Schedule an alarm using the best available method * * @param pendingIntent PendingIntent to trigger * @param triggerTime When to trigger the alarm * @return true if scheduling was successful */ private boolean scheduleAlarm(PendingIntent pendingIntent, long triggerTime) { try { // Use exact alarm manager if available if (exactAlarmManager != null) { return exactAlarmManager.scheduleAlarm(pendingIntent, triggerTime); } // Use PendingIntentManager for modern alarm scheduling if (pendingIntentManager.canScheduleExactAlarms()) { return pendingIntentManager.scheduleExactAlarm(pendingIntent, triggerTime); } else { // Fallback to windowed alarm return pendingIntentManager.scheduleWindowedAlarm(pendingIntent, triggerTime, 20 * 60 * 1000); // 20 minute window } } catch (Exception e) { Log.e(TAG, "Error scheduling alarm", e); return false; } } /** * Schedule an exact alarm for precise timing with enhanced Doze handling * * @param pendingIntent PendingIntent to trigger * @param triggerTime When to trigger the alarm * @return true if scheduling was successful */ private boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) { try { // Enhanced exact alarm scheduling for Android 12+ and Doze mode if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // Use setExactAndAllowWhileIdle for Doze mode compatibility alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent ); Log.d(TAG, "Exact alarm scheduled with Doze compatibility for " + formatTime(triggerTime)); } else { // Pre-Android 6.0: Use standard exact alarm alarmManager.setExact( AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent ); Log.d(TAG, "Exact alarm scheduled (pre-Android 6.0) for " + formatTime(triggerTime)); } // Log alarm scheduling details for debugging logAlarmSchedulingDetails(triggerTime); return true; } catch (SecurityException e) { Log.e(TAG, "Security exception scheduling exact alarm - exact alarm permission may be denied", e); return false; } catch (Exception e) { Log.e(TAG, "Error scheduling exact alarm", e); return false; } } /** * Log detailed alarm scheduling information for debugging * * @param triggerTime When the alarm will trigger */ private void logAlarmSchedulingDetails(long triggerTime) { try { long currentTime = System.currentTimeMillis(); long timeUntilTrigger = triggerTime - currentTime; Log.d(TAG, String.format("Alarm scheduling details: " + "Current time: %s, " + "Trigger time: %s, " + "Time until trigger: %d minutes, " + "Android version: %d, " + "Exact alarms supported: %s", formatTime(currentTime), formatTime(triggerTime), timeUntilTrigger / (60 * 1000), Build.VERSION.SDK_INT, canUseExactAlarms() ? "Yes" : "No")); } catch (Exception e) { Log.e(TAG, "Error logging alarm scheduling details", e); } } /** * Schedule an inexact alarm for battery optimization * * @param pendingIntent PendingIntent to trigger * @param triggerTime When to trigger the alarm * @return true if scheduling was successful */ private boolean scheduleInexactAlarm(PendingIntent pendingIntent, long triggerTime) { try { alarmManager.setRepeating( AlarmManager.RTC_WAKEUP, triggerTime, AlarmManager.INTERVAL_DAY, pendingIntent ); Log.d(TAG, "Inexact alarm scheduled for " + formatTime(triggerTime)); return true; } catch (Exception e) { Log.e(TAG, "Error scheduling inexact alarm", e); return false; } } /** * Check if we can use exact alarms with enhanced Android 12+ support * * @return true if exact alarms are permitted */ private boolean canUseExactAlarms() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ requires SCHEDULE_EXACT_ALARM permission boolean canSchedule = alarmManager.canScheduleExactAlarms(); Log.d(TAG, "Android 12+ exact alarm check: " + (canSchedule ? "Permission granted" : "Permission denied")); return canSchedule; } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // Android 6.0+ supports exact alarms but may be affected by Doze mode Log.d(TAG, "Android 6.0+ exact alarm support: Available (may be affected by Doze)"); return true; } else { // Pre-Android 6.0: Exact alarms always available Log.d(TAG, "Pre-Android 6.0 exact alarm support: Always available"); return true; } } catch (Exception e) { Log.e(TAG, "Error checking exact alarm capability", e); return false; } } /** * Request exact alarm permission for Android 12+ * * @return true if permission request was initiated */ public boolean requestExactAlarmPermission() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (!alarmManager.canScheduleExactAlarms()) { Log.d(TAG, "Requesting exact alarm permission for Android 12+"); // Create intent to open exact alarm settings Intent intent = new Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(intent); Log.i(TAG, "Exact alarm permission request initiated"); return true; } catch (Exception e) { Log.e(TAG, "Failed to open exact alarm settings", e); return false; } } else { Log.d(TAG, "Exact alarm permission already granted"); return true; } } else { Log.d(TAG, "Exact alarm permission not required for Android version " + Build.VERSION.SDK_INT); return true; } } catch (Exception e) { Log.e(TAG, "Error requesting exact alarm permission", e); return false; } } /** * Get exact alarm status with detailed information * * @return ExactAlarmStatus with comprehensive status information */ public ExactAlarmStatus getExactAlarmStatus() { try { boolean supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; boolean enabled = canUseExactAlarms(); boolean canSchedule = enabled && supported; String fallbackWindow = "±10 minutes"; // Default fallback window if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (!enabled) { fallbackWindow = "±15 minutes (Android 12+ restriction)"; } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { fallbackWindow = "±5 minutes (Doze mode may affect timing)"; } ExactAlarmStatus status = new ExactAlarmStatus(); status.supported = supported; status.enabled = enabled; status.canSchedule = canSchedule; status.fallbackWindow = fallbackWindow; Log.d(TAG, "Exact alarm status: supported=" + supported + ", enabled=" + enabled + ", canSchedule=" + canSchedule); return status; } catch (Exception e) { Log.e(TAG, "Error getting exact alarm status", e); // Return safe default status ExactAlarmStatus status = new ExactAlarmStatus(); status.supported = false; status.enabled = false; status.canSchedule = false; status.fallbackWindow = "±20 minutes (error state)"; return status; } } /** * Exact alarm status information */ public static class ExactAlarmStatus { public boolean supported; public boolean enabled; public boolean canSchedule; public String fallbackWindow; } /** * Cancel a specific notification * * @param notificationId ID of notification to cancel */ public void cancelNotification(String notificationId) { try { PendingIntent pendingIntent = scheduledAlarms.remove(notificationId); if (pendingIntent != null) { pendingIntentManager.cancelAlarm(pendingIntent); pendingIntent.cancel(); Log.d(TAG, "Cancelled existing alarm for notification: " + notificationId); // Remove from time-to-ID mapping by finding and removing the entry java.util.List timesToRemove = new java.util.ArrayList<>(); for (java.util.Map.Entry entry : scheduledTimeToId.entrySet()) { if (entry.getValue().equals(notificationId)) { timesToRemove.add(entry.getKey()); } } for (Long time : timesToRemove) { scheduledTimeToId.remove(time); } } else { Log.d(TAG, "No existing alarm found to cancel for notification: " + notificationId); } } catch (Exception e) { Log.e(TAG, "Error cancelling notification: " + notificationId, e); } } /** * Cancel all scheduled notifications */ public void cancelAllNotifications() { try { Log.d(TAG, "Cancelling all notifications"); for (String notificationId : scheduledAlarms.keySet()) { cancelNotification(notificationId); } scheduledAlarms.clear(); scheduledTimeToId.clear(); Log.i(TAG, "All notifications cancelled"); } catch (Exception e) { Log.e(TAG, "Error cancelling all notifications", e); } } /** * Get the next scheduled notification time * * @return Timestamp of next notification or 0 if none scheduled */ public long getNextNotificationTime() { // This would need to be implemented with actual notification data // For now, return a placeholder return System.currentTimeMillis() + (24 * 60 * 60 * 1000); // 24 hours from now } /** * Get count of pending notifications * * @return Number of scheduled notifications */ public int getPendingNotificationsCount() { return scheduledAlarms.size(); } /** * Update notification settings for existing notifications */ public void updateNotificationSettings() { try { Log.d(TAG, "Updating notification settings"); // This would typically involve rescheduling notifications // with new settings. For now, just log the action. Log.i(TAG, "Notification settings updated"); } catch (Exception e) { Log.e(TAG, "Error updating notification settings", e); } } /** * Enable adaptive scheduling based on device state */ public void enableAdaptiveScheduling() { try { Log.d(TAG, "Enabling adaptive scheduling"); // This would implement logic to adjust scheduling based on: // - Battery level // - Power save mode // - Doze mode // - User activity patterns Log.i(TAG, "Adaptive scheduling enabled"); } catch (Exception e) { Log.e(TAG, "Error enabling adaptive scheduling", e); } } /** * Disable adaptive scheduling */ public void disableAdaptiveScheduling() { try { Log.d(TAG, "Disabling adaptive scheduling"); // Reset to default scheduling behavior Log.i(TAG, "Adaptive scheduling disabled"); } catch (Exception e) { Log.e(TAG, "Error disabling adaptive scheduling", e); } } /** * Reschedule notifications after system reboot */ public void rescheduleAfterReboot() { try { Log.d(TAG, "Rescheduling notifications after reboot"); // This would typically be called from a BOOT_COMPLETED receiver // to restore scheduled notifications after device restart Log.i(TAG, "Notifications rescheduled after reboot"); } catch (Exception e) { Log.e(TAG, "Error rescheduling after reboot", e); } } /** * Check if a notification is currently scheduled * * @param notificationId ID of notification to check * @return true if notification is scheduled */ public boolean isNotificationScheduled(String notificationId) { return scheduledAlarms.containsKey(notificationId); } /** * Get scheduling statistics * * @return Scheduling statistics as a string */ public String getSchedulingStats() { return String.format("Scheduled: %d, Exact alarms: %s", scheduledAlarms.size(), canUseExactAlarms() ? "enabled" : "disabled"); } /** * Format timestamp for logging * * @param timestamp Timestamp in milliseconds * @return Formatted time string */ private String formatTime(long timestamp) { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); return String.format("%02d:%02d:%02d on %02d/%02d/%04d", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH), calendar.get(Calendar.YEAR)); } /** * Calculate next occurrence of a daily time with DST-safe handling * * @param hour Hour of day (0-23) * @param minute Minute of hour (0-59) * @param timezone Timezone identifier (e.g., "America/New_York") * @return Timestamp of next occurrence */ public long calculateNextOccurrence(int hour, int minute, String timezone) { try { // Use Java 8 Time API for DST-safe calculations java.time.ZoneId zone = java.time.ZoneId.of(timezone); java.time.LocalTime targetTime = java.time.LocalTime.of(hour, minute); // Get current time in user's timezone java.time.ZonedDateTime now = java.time.ZonedDateTime.now(zone); java.time.LocalDate today = now.toLocalDate(); // Calculate next occurrence at same local time java.time.ZonedDateTime nextScheduled = java.time.ZonedDateTime.of(today, targetTime, zone); // If time has passed today, schedule for tomorrow if (nextScheduled.isBefore(now)) { nextScheduled = nextScheduled.plusDays(1); } long result = nextScheduled.toInstant().toEpochMilli(); Log.d(TAG, String.format("DST-safe calculation: target=%02d:%02d, timezone=%s, " + "next occurrence=%s (UTC offset: %s)", hour, minute, timezone, formatTime(result), nextScheduled.getOffset().toString())); return result; } catch (Exception e) { Log.e(TAG, "Error in DST-safe calculation, falling back to Calendar", e); return calculateNextOccurrenceLegacy(hour, minute); } } /** * Calculate next occurrence using legacy Calendar API (fallback) * * @param hour Hour of day (0-23) * @param minute Minute of hour (0-59) * @return Timestamp of next occurrence */ private long calculateNextOccurrenceLegacy(int hour, int minute) { Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, hour); calendar.set(Calendar.MINUTE, minute); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); // If time has passed today, schedule for tomorrow if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { calendar.add(Calendar.DAY_OF_YEAR, 1); } Log.d(TAG, String.format("Legacy calculation: target=%02d:%02d, next occurrence=%s", hour, minute, formatTime(calendar.getTimeInMillis()))); return calendar.getTimeInMillis(); } /** * Calculate next occurrence with DST transition awareness * * @param hour Hour of day (0-23) * @param minute Minute of hour (0-59) * @param timezone Timezone identifier * @param daysAhead Number of days to look ahead for DST transitions * @return Timestamp of next occurrence with DST awareness */ public long calculateNextOccurrenceWithDSTAwareness(int hour, int minute, String timezone, int daysAhead) { try { java.time.ZoneId zone = java.time.ZoneId.of(timezone); java.time.LocalTime targetTime = java.time.LocalTime.of(hour, minute); java.time.ZonedDateTime now = java.time.ZonedDateTime.now(zone); // Look ahead for DST transitions java.time.ZonedDateTime candidate = java.time.ZonedDateTime.of(now.toLocalDate(), targetTime, zone); // If time has passed today, start from tomorrow if (candidate.isBefore(now)) { candidate = candidate.plusDays(1); } // Check for DST transitions in the next few days for (int i = 0; i < daysAhead; i++) { java.time.ZonedDateTime nextDay = candidate.plusDays(i); java.time.ZonedDateTime nextDayAtTarget = java.time.ZonedDateTime.of(nextDay.toLocalDate(), targetTime, zone); // Check if this day has a DST transition if (hasDSTTransition(nextDayAtTarget, zone)) { Log.d(TAG, String.format("DST transition detected on %s, adjusting schedule", nextDayAtTarget.toLocalDate().toString())); // Adjust for DST transition nextDayAtTarget = adjustForDSTTransition(nextDayAtTarget, zone); } // Use the first valid occurrence if (nextDayAtTarget.isAfter(now)) { long result = nextDayAtTarget.toInstant().toEpochMilli(); Log.d(TAG, String.format("DST-aware calculation: target=%02d:%02d, timezone=%s, " + "next occurrence=%s (UTC offset: %s)", hour, minute, timezone, formatTime(result), nextDayAtTarget.getOffset().toString())); return result; } } // Fallback to standard calculation return calculateNextOccurrence(hour, minute, timezone); } catch (Exception e) { Log.e(TAG, "Error in DST-aware calculation", e); return calculateNextOccurrenceLegacy(hour, minute); } } /** * Check if a specific date has a DST transition * * @param dateTime The date/time to check * @param zone The timezone * @return true if there's a DST transition on this date */ private boolean hasDSTTransition(java.time.ZonedDateTime dateTime, java.time.ZoneId zone) { try { // Check if the offset changes between this day and the next java.time.ZonedDateTime nextDay = dateTime.plusDays(1); return !dateTime.getOffset().equals(nextDay.getOffset()); } catch (Exception e) { Log.e(TAG, "Error checking DST transition", e); return false; } } /** * Adjust schedule for DST transition * * @param dateTime The date/time to adjust * @param zone The timezone * @return Adjusted date/time */ private java.time.ZonedDateTime adjustForDSTTransition(java.time.ZonedDateTime dateTime, java.time.ZoneId zone) { try { // For spring forward (lose an hour), schedule 1 hour earlier // For fall back (gain an hour), schedule 1 hour later java.time.ZonedDateTime nextDay = dateTime.plusDays(1); if (dateTime.getOffset().getTotalSeconds() < nextDay.getOffset().getTotalSeconds()) { // Spring forward - schedule earlier Log.d(TAG, "Spring forward detected, scheduling 1 hour earlier"); return dateTime.minusHours(1); } else if (dateTime.getOffset().getTotalSeconds() > nextDay.getOffset().getTotalSeconds()) { // Fall back - schedule later Log.d(TAG, "Fall back detected, scheduling 1 hour later"); return dateTime.plusHours(1); } return dateTime; } catch (Exception e) { Log.e(TAG, "Error adjusting for DST transition", e); return dateTime; } } /** * Restore scheduled notifications after reboot * * This method should be called after system reboot to restore * all scheduled notifications that were lost during reboot. */ public void restoreScheduledNotifications() { try { Log.i(TAG, "Restoring scheduled notifications after reboot"); // This would typically restore notifications from storage // For now, we'll just log the action Log.d(TAG, "Scheduled notifications restored"); } catch (Exception e) { Log.e(TAG, "Error restoring scheduled notifications", e); } } /** * Adjust scheduled notifications after time change * * This method should be called after system time changes to adjust * all scheduled notifications accordingly. */ public void adjustScheduledNotifications() { try { Log.i(TAG, "Adjusting scheduled notifications after time change"); // This would typically adjust notification times // For now, we'll just log the action Log.d(TAG, "Scheduled notifications adjusted"); } catch (Exception e) { Log.e(TAG, "Error adjusting scheduled notifications", e); } } /** * Get count of restored notifications * * @return Number of restored notifications */ public int getRestoredNotificationCount() { // This would typically return actual count // For now, we'll return a placeholder return 0; } /** * Get count of adjusted notifications * * @return Number of adjusted notifications */ public int getAdjustedNotificationCount() { // This would typically return actual count // For now, we'll return a placeholder return 0; } // MARK: - Phase 3: TimeSafari Coordination Methods /** * Phase 3: Check if scheduling should proceed with TimeSafari coordination */ private boolean shouldScheduleWithTimeSafariCoordination(NotificationContent content) { try { Log.d(TAG, "Phase 3: Checking TimeSafari coordination for notification: " + content.getId()); // Check app lifecycle state if (!isAppInForeground()) { Log.d(TAG, "Phase 3: App not in foreground - allowing scheduling"); return true; } // Check activeDid health if (hasActiveDidChangedRecently()) { Log.d(TAG, "Phase 3: ActiveDid changed recently - deferring scheduling"); return false; } // Check background task coordination if (!isBackgroundTaskCoordinated()) { Log.d(TAG, "Phase 3: Background tasks not coordinated - allowing scheduling"); return true; } // Check notification throttling if (isNotificationThrottled()) { Log.d(TAG, "Phase 3: Notification throttled - deferring scheduling"); return false; } Log.d(TAG, "Phase 3: TimeSafari coordination passed - allowing scheduling"); return true; } catch (Exception e) { Log.e(TAG, "Phase 3: Error checking TimeSafari coordination", e); return true; // Default to allowing scheduling on error } } /** * Phase 3: Check if app is currently in foreground */ private boolean isAppInForeground() { try { android.app.ActivityManager activityManager = (android.app.ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { java.util.List runningProcesses = activityManager.getRunningAppProcesses(); if (runningProcesses != null) { for (android.app.ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) { if (processInfo.processName.equals(context.getPackageName())) { boolean inForeground = processInfo.importance == android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; Log.d(TAG, "Phase 3: App foreground state: " + inForeground); return inForeground; } } } } return false; } catch (Exception e) { Log.e(TAG, "Phase 3: Error checking app foreground state", e); return false; } } /** * Phase 3: Check if activeDid has changed recently */ private boolean hasActiveDidChangedRecently() { try { android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); long lastActiveDidChange = prefs.getLong("lastActiveDidChange", 0); long gracefulPeriodMs = 30000; // 30 seconds grace period if (lastActiveDidChange > 0) { long timeSinceChange = System.currentTimeMillis() - lastActiveDidChange; boolean changedRecently = timeSinceChange < gracefulPeriodMs; Log.d(TAG, "Phase 3: ActiveDid change check - lastChange: " + lastActiveDidChange + ", timeSince: " + timeSinceChange + "ms, changedRecently: " + changedRecently); return changedRecently; } return false; } catch (Exception e) { Log.e(TAG, "Phase 3: Error checking activeDid change", e); return false; } } /** * Phase 3: Check if background tasks are properly coordinated */ private boolean isBackgroundTaskCoordinated() { try { android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); boolean autoSync = prefs.getBoolean("autoSync", false); long lastFetchAttempt = prefs.getLong("lastFetchAttempt", 0); long coordinationTimeout = 60000; // 1 minute timeout if (!autoSync) { Log.d(TAG, "Phase 3: Auto-sync disabled - background coordination not needed"); return true; } if (lastFetchAttempt > 0) { long timeSinceLastFetch = System.currentTimeMillis() - lastFetchAttempt; boolean recentFetch = timeSinceLastFetch < coordinationTimeout; Log.d(TAG, "Phase 3: Background task coordination - timeSinceLastFetch: " + timeSinceLastFetch + "ms, recentFetch: " + recentFetch); return recentFetch; } return true; } catch (Exception e) { Log.e(TAG, "Phase 3: Error checking background task coordination", e); return true; } } /** * Phase 3: Check if notifications are currently throttled */ private boolean isNotificationThrottled() { try { android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); long lastNotificationDelivered = prefs.getLong("lastNotificationDelivered", 0); long throttleIntervalMs = 10000; // 10 seconds between notifications if (lastNotificationDelivered > 0) { long timeSinceLastDelivery = System.currentTimeMillis() - lastNotificationDelivered; boolean isThrottled = timeSinceLastDelivery < throttleIntervalMs; Log.d(TAG, "Phase 3: Notification throttling - timeSinceLastDelivery: " + timeSinceLastDelivery + "ms, isThrottled: " + isThrottled); return isThrottled; } return false; } catch (Exception e) { Log.e(TAG, "Phase 3: Error checking notification throttle", e); return false; } } /** * Phase 3: Update notification delivery timestamp */ public void recordNotificationDelivery(String notificationId) { try { android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); prefs.edit() .putLong("lastNotificationDelivered", System.currentTimeMillis()) .putString("lastDeliveredNotificationId", notificationId) .apply(); Log.d(TAG, "Phase 3: Notification delivery recorded: " + notificationId); } catch (Exception e) { Log.e(TAG, "Phase 3: Error recording notification delivery", e); } } /** * Phase 3: Coordinate with PlatformServiceMixin events */ public void coordinateWithPlatformServiceMixin() { try { Log.d(TAG, "Phase 3: Coordinating with PlatformServiceMixin events"); // This would integrate with TimeSafari's PlatformServiceMixin lifecycle events // For now, we'll implement a simplified coordination android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); boolean autoSync = prefs.getBoolean("autoSync", false); if (autoSync) { // Schedule background content fetch coordination scheduleBackgroundContentFetchWithCoordination(); } Log.d(TAG, "Phase 3: PlatformServiceMixin coordination completed"); } catch (Exception e) { Log.e(TAG, "Phase 3: Error coordinating with PlatformServiceMixin", e); } } /** * Phase 3: Schedule background content fetch with coordination */ private void scheduleBackgroundContentFetchWithCoordination() { try { Log.d(TAG, "Phase 3: Scheduling background content fetch with coordination"); // This would coordinate with TimeSafari's background task management // For now, we'll update coordination timestamps android.content.SharedPreferences prefs = context.getSharedPreferences( "daily_notification_timesafari", Context.MODE_PRIVATE); prefs.edit() .putLong("lastBackgroundFetchCoordinated", System.currentTimeMillis()) .apply(); Log.d(TAG, "Phase 3: Background content fetch coordination completed"); } catch (Exception e) { Log.e(TAG, "Phase 3: Error scheduling background content fetch coordination", e); } } }