You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1102 lines
42 KiB
1102 lines
42 KiB
/**
|
|
* 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<String, PendingIntent> scheduledAlarms;
|
|
|
|
// Track scheduled times to prevent duplicate alarms for same time
|
|
// Maps scheduledTime (ms) -> notificationId that has alarm scheduled
|
|
private final ConcurrentHashMap<Long, String> 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<String> duplicateIds = new java.util.ArrayList<>();
|
|
for (java.util.Map.Entry<Long, String> 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<Long> timesToRemove = new java.util.ArrayList<>();
|
|
for (java.util.Map.Entry<Long, String> 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<android.app.ActivityManager.RunningAppProcessInfo> 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);
|
|
}
|
|
}
|
|
}
|
|
|