feat(plugin): add optional rolloverIntervalMinutes for dev/testing
Add optional rolloverIntervalMinutes to scheduleDailyNotification so the next occurrence can be scheduled N minutes after the current trigger (e.g. 10 minutes) instead of 24 hours. Value is persisted and used on rollover and after reboot. - TypeScript: NotificationOptions.rolloverIntervalMinutes?: number - Android: Schedule.rolloverIntervalMinutes in Room (migration 2→3); Plugin and ScheduleHelper persist it; Worker uses it in rollover and updates nextRunAt; ReactivationManager uses it in boot recovery - iOS: NotificationContent.rolloverIntervalMinutes (Codable); Plugin passes it into content; Scheduler uses it in calculateNextScheduledTime and copies to nextContent on rollover When absent or ≤0, behavior unchanged (24h). App can clear by calling scheduleDailyNotification without the parameter.
This commit is contained in:
@@ -1109,8 +1109,13 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
val sound = options.getBoolean("sound") ?: true
|
val sound = options.getBoolean("sound") ?: true
|
||||||
val priority = options.getString("priority") ?: "default"
|
val priority = options.getString("priority") ?: "default"
|
||||||
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
|
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
|
||||||
|
val rolloverIntervalMinutes = try {
|
||||||
|
(options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
|
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
|
||||||
|
|
||||||
// Convert HH:mm time to cron expression (daily at specified time)
|
// Convert HH:mm time to cron expression (daily at specified time)
|
||||||
val cronExpression = convertTimeToCron(time)
|
val cronExpression = convertTimeToCron(time)
|
||||||
@@ -1162,7 +1167,8 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
config,
|
config,
|
||||||
time,
|
time,
|
||||||
::calculateNextRunTime
|
::calculateNextRunTime,
|
||||||
|
rolloverIntervalMinutes
|
||||||
)
|
)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -2658,6 +2664,7 @@ object ScheduleHelper {
|
|||||||
* @param config User notification configuration
|
* @param config User notification configuration
|
||||||
* @param clockTime Original HH:mm time string
|
* @param clockTime Original HH:mm time string
|
||||||
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
||||||
|
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
|
||||||
* @return true if successful, false otherwise
|
* @return true if successful, false otherwise
|
||||||
*/
|
*/
|
||||||
suspend fun scheduleDailyNotification(
|
suspend fun scheduleDailyNotification(
|
||||||
@@ -2666,7 +2673,8 @@ object ScheduleHelper {
|
|||||||
scheduleId: String,
|
scheduleId: String,
|
||||||
config: UserNotificationConfig,
|
config: UserNotificationConfig,
|
||||||
clockTime: String,
|
clockTime: String,
|
||||||
calculateNextRunTime: (String) -> Long
|
calculateNextRunTime: (String) -> Long,
|
||||||
|
rolloverIntervalMinutes: Int? = null
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val nextRunTime = calculateNextRunTime(config.schedule)
|
val nextRunTime = calculateNextRunTime(config.schedule)
|
||||||
@@ -2697,14 +2705,15 @@ object ScheduleHelper {
|
|||||||
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
||||||
// notifications at fire time.
|
// notifications at fire time.
|
||||||
|
|
||||||
// Store schedule in database
|
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
|
||||||
val schedule = Schedule(
|
val schedule = Schedule(
|
||||||
id = scheduleId,
|
id = scheduleId,
|
||||||
kind = "notify",
|
kind = "notify",
|
||||||
cron = config.schedule,
|
cron = config.schedule,
|
||||||
clockTime = clockTime,
|
clockTime = clockTime,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
nextRunAt = nextRunTime
|
nextRunAt = nextRunTime,
|
||||||
|
rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||||
)
|
)
|
||||||
database.scheduleDao().upsert(schedule)
|
database.scheduleDao().upsert(schedule)
|
||||||
|
|
||||||
@@ -2743,6 +2752,35 @@ object ScheduleHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking load of a schedule by id (for use from Java Worker / rollover path).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
|
||||||
|
return kotlinx.coroutines.runBlocking {
|
||||||
|
try {
|
||||||
|
DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking update of schedule next run time (for use from Java Worker after rollover).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
try {
|
||||||
|
DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule dual notification (fetch + notify)
|
* Schedule dual notification (fetch + notify)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -535,8 +535,33 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
|
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
|
||||||
|
|
||||||
// Calculate next occurrence using DST-safe ZonedDateTime
|
// Resolve schedule_id first so we can load rollover interval from DB
|
||||||
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
Data inputDataForSchedule = getInputData();
|
||||||
|
boolean preserveStaticReminder = inputDataForSchedule.getBoolean("is_static_reminder", false);
|
||||||
|
String scheduleIdForRollover = inputDataForSchedule.getString("schedule_id");
|
||||||
|
if (scheduleIdForRollover == null || scheduleIdForRollover.isEmpty()) {
|
||||||
|
String notificationId = content.getId();
|
||||||
|
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||||
|
scheduleIdForRollover = notificationId;
|
||||||
|
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||||
|
scheduleIdForRollover = notificationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Integer rolloverMinutes = null;
|
||||||
|
if (scheduleIdForRollover != null && !scheduleIdForRollover.isEmpty()) {
|
||||||
|
com.timesafari.dailynotification.Schedule s = com.timesafari.dailynotification.ScheduleHelper.getScheduleBlocking(getApplicationContext(), scheduleIdForRollover);
|
||||||
|
if (s != null && s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
|
||||||
|
rolloverMinutes = s.getRolloverIntervalMinutes();
|
||||||
|
Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + scheduleIdForRollover + " minutes=" + rolloverMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long nextScheduledTime;
|
||||||
|
if (rolloverMinutes != null && rolloverMinutes > 0) {
|
||||||
|
nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
|
||||||
|
Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
|
||||||
|
} else {
|
||||||
|
nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||||
|
}
|
||||||
|
|
||||||
// Check for existing notification at the same time to prevent duplicates
|
// Check for existing notification at the same time to prevent duplicates
|
||||||
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
|
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
|
||||||
@@ -561,36 +586,30 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve static reminder semantics across rollover; use stable schedule_id so reschedule cancels this alarm
|
|
||||||
Data inputData = getInputData();
|
|
||||||
boolean preserveStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
|
||||||
String scheduleId = inputData.getString("schedule_id");
|
|
||||||
if (scheduleId == null || scheduleId.isEmpty()) {
|
|
||||||
String notificationId = content.getId();
|
|
||||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
|
||||||
scheduleId = notificationId;
|
|
||||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
|
||||||
scheduleId = notificationId;
|
|
||||||
} else {
|
|
||||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String cronExpression = null;
|
String cronExpression = null;
|
||||||
String notificationId = content.getId();
|
String notificationId = content.getId();
|
||||||
|
String scheduleId = scheduleIdForRollover;
|
||||||
|
if (scheduleId == null || scheduleId.isEmpty()) {
|
||||||
|
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate cron from current scheduled time (extract hour:minute)
|
// When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
|
||||||
try {
|
if (rolloverMinutes == null || rolloverMinutes <= 0) {
|
||||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
try {
|
||||||
cal.setTimeInMillis(content.getScheduledTime());
|
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||||
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
cal.setTimeInMillis(content.getScheduledTime());
|
||||||
int minute = cal.get(java.util.Calendar.MINUTE);
|
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
||||||
cronExpression = String.format("%d %d * * *", minute, hour);
|
int minute = cal.get(java.util.Calendar.MINUTE);
|
||||||
|
cronExpression = String.format("%d %d * * *", minute, hour);
|
||||||
// Recalculate next run time from cron (tomorrow at same time)
|
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
|
||||||
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
|
} catch (Exception e) {
|
||||||
} catch (Exception e) {
|
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
||||||
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
cronExpression = "0 9 * * *";
|
||||||
cronExpression = "0 9 * * *"; // Default to 9 AM
|
}
|
||||||
|
} else {
|
||||||
|
cronExpression = String.format("%d %d * * *",
|
||||||
|
java.util.Calendar.getInstance().get(java.util.Calendar.MINUTE),
|
||||||
|
java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create config for next notification
|
// Create config for next notification
|
||||||
@@ -617,7 +636,10 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||||
);
|
);
|
||||||
|
if (scheduleId != null && !scheduleId.startsWith("daily_rollover_")) {
|
||||||
|
com.timesafari.dailynotification.ScheduleHelper.updateScheduleNextRunTimeBlocking(
|
||||||
|
getApplicationContext(), scheduleId, content.getScheduledTime(), nextScheduledTime);
|
||||||
|
}
|
||||||
// Log next scheduled time in readable format
|
// Log next scheduled time in readable format
|
||||||
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
||||||
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
||||||
@@ -771,6 +793,21 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add minutes to a timestamp (DST-safe via Calendar).
|
||||||
|
* Used for rollover interval (e.g. 10 minutes for testing).
|
||||||
|
*/
|
||||||
|
private long addMinutesToTime(long timeMillis, int minutes) {
|
||||||
|
try {
|
||||||
|
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||||
|
cal.setTimeInMillis(timeMillis);
|
||||||
|
cal.add(java.util.Calendar.MINUTE, minutes);
|
||||||
|
return cal.getTimeInMillis();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return timeMillis + (minutes * 60 * 1000L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate next scheduled time with DST-safe handling
|
* Calculate next scheduled time with DST-safe handling
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ data class Schedule(
|
|||||||
val nextRunAt: Long? = null,
|
val nextRunAt: Long? = null,
|
||||||
val jitterMs: Int = 0,
|
val jitterMs: Int = 0,
|
||||||
val backoffPolicy: String = "exp",
|
val backoffPolicy: String = "exp",
|
||||||
val stateJson: String? = null
|
val stateJson: String? = null,
|
||||||
|
/** When > 0, next occurrence is this many minutes after current trigger (dev/testing). Null or 0 = 24h. */
|
||||||
|
val rolloverIntervalMinutes: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "callbacks")
|
@Entity(tableName = "callbacks")
|
||||||
@@ -83,7 +85,7 @@ data class History(
|
|||||||
NotificationDeliveryEntity::class,
|
NotificationDeliveryEntity::class,
|
||||||
NotificationConfigEntity::class
|
NotificationConfigEntity::class
|
||||||
],
|
],
|
||||||
version = 2, // Incremented for unified schema
|
version = 3, // 3: add rollover_interval_minutes to schedules
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -118,7 +120,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
|||||||
DailyNotificationDatabase::class.java,
|
DailyNotificationDatabase::class.java,
|
||||||
DATABASE_NAME
|
DATABASE_NAME
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // 1->2: unified; 2->3: rollover_interval_minutes
|
||||||
.addCallback(roomCallback)
|
.addCallback(roomCallback)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
@@ -266,6 +268,15 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
|||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration from version 2 to 3: add rollover_interval_minutes to schedules
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ class ReactivationManager(private val context: Context) {
|
|||||||
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||||
missedCount++
|
missedCount++
|
||||||
|
|
||||||
// Schedule next occurrence if repeating
|
// Schedule next occurrence (use rollover interval if set, else 24h)
|
||||||
val nextOccurrence = calculateNextOccurrence(currentTime)
|
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
|
||||||
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
||||||
rescheduledCount++
|
rescheduledCount++
|
||||||
|
|
||||||
@@ -238,11 +238,26 @@ class ReactivationManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateNextOccurrence(fromTime: Long): Long {
|
private fun calculateNextOccurrence(fromTime: Long): Long {
|
||||||
// For daily schedules, add 24 hours
|
|
||||||
// This is simplified - production should handle weekly/monthly patterns
|
|
||||||
return fromTime + (24 * 60 * 60 * 1000L)
|
return fromTime + (24 * 60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next occurrence from a given trigger time. Uses schedule.rolloverIntervalMinutes when set and > 0 (dev/testing), else 24h.
|
||||||
|
* Advances until result > currentTime so we don't reschedule in the past.
|
||||||
|
*/
|
||||||
|
private fun calculateNextOccurrenceForSchedule(schedule: Schedule, fromTime: Long, currentTime: Long): Long {
|
||||||
|
val intervalMs = when {
|
||||||
|
schedule.rolloverIntervalMinutes != null && schedule.rolloverIntervalMinutes!! > 0 ->
|
||||||
|
schedule.rolloverIntervalMinutes!! * 60 * 1000L
|
||||||
|
else -> 24 * 60 * 60 * 1000L
|
||||||
|
}
|
||||||
|
var next = fromTime + intervalMs
|
||||||
|
while (next < currentTime) {
|
||||||
|
next += intervalMs
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun markMissedNotificationForSchedule(
|
private suspend fun markMissedNotificationForSchedule(
|
||||||
schedule: Schedule,
|
schedule: Schedule,
|
||||||
scheduledTime: Long,
|
scheduledTime: Long,
|
||||||
|
|||||||
@@ -1118,12 +1118,14 @@ public class DailyNotificationPlugin: CAPPlugin {
|
|||||||
let sound = call.getBool("sound", true)
|
let sound = call.getBool("sound", true)
|
||||||
let url = call.getString("url")
|
let url = call.getString("url")
|
||||||
let priority = call.getString("priority") ?? "default"
|
let priority = call.getString("priority") ?? "default"
|
||||||
|
let rawRollover = call.getInt("rolloverIntervalMinutes") ?? 0
|
||||||
|
let rolloverIntervalMinutes = rawRollover > 0 ? rawRollover : nil
|
||||||
|
|
||||||
// Calculate scheduled time (next occurrence at specified hour:minute)
|
// Calculate scheduled time (next occurrence at specified hour:minute)
|
||||||
let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute)
|
let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute)
|
||||||
let fetchedAt = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds
|
let fetchedAt = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds
|
||||||
|
|
||||||
// Create notification content
|
// Create notification content (persist rollover interval for dev/testing; survives app restart)
|
||||||
let content = NotificationContent(
|
let content = NotificationContent(
|
||||||
id: "daily_\(Date().timeIntervalSince1970)",
|
id: "daily_\(Date().timeIntervalSince1970)",
|
||||||
title: title,
|
title: title,
|
||||||
@@ -1132,7 +1134,8 @@ public class DailyNotificationPlugin: CAPPlugin {
|
|||||||
fetchedAt: fetchedAt,
|
fetchedAt: fetchedAt,
|
||||||
url: url,
|
url: url,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
etag: nil
|
etag: nil,
|
||||||
|
rolloverIntervalMinutes: rolloverIntervalMinutes
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delegate to ScheduleHelper for orchestration
|
// Delegate to ScheduleHelper for orchestration
|
||||||
|
|||||||
@@ -382,55 +382,24 @@ class DailyNotificationScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate next scheduled time from current scheduled time (24 hours later, DST-safe)
|
* Calculate next scheduled time from current (24h or rollover interval minutes). DST-safe.
|
||||||
*
|
* When rolloverIntervalMinutes > 0 (dev/testing), adds that many minutes; otherwise adds 24 hours.
|
||||||
* Matches Android calculateNextScheduledTime() functionality
|
|
||||||
* Handles DST transitions automatically using Calendar
|
|
||||||
*
|
|
||||||
* @param currentScheduledTime Current scheduled time in milliseconds
|
|
||||||
* @return Next scheduled time in milliseconds (24 hours later)
|
|
||||||
*
|
|
||||||
* TESTING: To test with shorter intervals (e.g., 2 minutes), change:
|
|
||||||
* - Line ~404: `.hour, value: 24` → `.minute, value: 2`
|
|
||||||
* - Line ~407: `(24 * 60 * 60 * 1000)` → `(2 * 60 * 1000)`
|
|
||||||
*/
|
*/
|
||||||
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
|
func calculateNextScheduledTime(_ currentScheduledTime: Int64, rolloverIntervalMinutes: Int? = nil) -> Int64 {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
|
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
|
||||||
let currentTimeStr = formatTime(currentScheduledTime)
|
let currentTimeStr = formatTime(currentScheduledTime)
|
||||||
|
let addMinutes = (rolloverIntervalMinutes ?? 0) > 0 ? rolloverIntervalMinutes! : (24 * 60)
|
||||||
// Add 24 hours (handles DST transitions automatically)
|
guard let nextDate = calendar.date(byAdding: .minute, value: addMinutes, to: currentDate) else {
|
||||||
// TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing
|
let fallbackTime = currentScheduledTime + (Int64(addMinutes) * 60 * 1000)
|
||||||
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback add_minutes=\(addMinutes)")
|
||||||
// Fallback to simple 24-hour addition if calendar calculation fails
|
|
||||||
// TESTING: Change `(24 * 60 * 60 * 1000)` to `(2 * 60 * 1000)` for 2-minute testing
|
|
||||||
let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000)
|
|
||||||
let fallbackTimeStr = formatTime(fallbackTime)
|
|
||||||
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
|
||||||
print("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
|
||||||
return fallbackTime
|
return fallbackTime
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||||
let nextTimeStr = formatTime(nextTime)
|
let nextTimeStr = formatTime(nextTime)
|
||||||
|
|
||||||
// Validate: Log DST transitions for debugging
|
|
||||||
let currentHour = calendar.component(.hour, from: currentDate)
|
|
||||||
let currentMinute = calendar.component(.minute, from: currentDate)
|
|
||||||
let nextHour = calendar.component(.hour, from: nextDate)
|
|
||||||
let nextMinute = calendar.component(.minute, from: nextDate)
|
|
||||||
|
|
||||||
if currentHour != nextHour || currentMinute != nextMinute {
|
|
||||||
NSLog("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
|
||||||
print("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the calculation result
|
|
||||||
let timeDiffMs = nextTime - currentScheduledTime
|
let timeDiffMs = nextTime - currentScheduledTime
|
||||||
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
||||||
NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours)) rollover_min=\(rolloverIntervalMinutes ?? 0)")
|
||||||
print("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
|
||||||
|
|
||||||
return nextTime
|
return nextTime
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,16 +442,16 @@ class DailyNotificationScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate next occurrence using DST-safe calculation
|
// Calculate next occurrence (use stored rollover interval for dev/testing, else 24h)
|
||||||
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
|
let rolloverMin = (content.rolloverIntervalMinutes ?? 0) > 0 ? content.rolloverIntervalMinutes : nil
|
||||||
|
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime, rolloverIntervalMinutes: rolloverMin)
|
||||||
|
|
||||||
// If next scheduled time is in the past, keep calculating forward until we get a future time
|
// If next scheduled time is in the past, keep calculating forward until we get a future time
|
||||||
// This handles cases where the notification fired more than 2 minutes ago
|
// This handles cases where the notification fired more than 2 minutes ago
|
||||||
while nextScheduledTime < currentTime {
|
while nextScheduledTime < currentTime {
|
||||||
let nextTimeStr = formatTime(nextScheduledTime)
|
let nextTimeStr = formatTime(nextScheduledTime)
|
||||||
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
|
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
|
||||||
print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
|
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime, rolloverIntervalMinutes: rolloverMin)
|
||||||
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextScheduledTimeStr = formatTime(nextScheduledTime)
|
let nextScheduledTimeStr = formatTime(nextScheduledTime)
|
||||||
@@ -539,13 +508,14 @@ class DailyNotificationScheduler {
|
|||||||
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
||||||
let nextContent = NotificationContent(
|
let nextContent = NotificationContent(
|
||||||
id: nextId,
|
id: nextId,
|
||||||
title: content.title, // Will be updated by prefetch
|
title: content.title,
|
||||||
body: content.body, // Will be updated by prefetch
|
body: content.body,
|
||||||
scheduledTime: nextScheduledTime,
|
scheduledTime: nextScheduledTime,
|
||||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
url: content.url,
|
url: content.url,
|
||||||
payload: content.payload,
|
payload: content.payload,
|
||||||
etag: content.etag
|
etag: content.etag,
|
||||||
|
rolloverIntervalMinutes: content.rolloverIntervalMinutes
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schedule the next notification
|
// Schedule the next notification
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class NotificationContent: Codable {
|
|||||||
let url: String?
|
let url: String?
|
||||||
let payload: [String: Any]?
|
let payload: [String: Any]?
|
||||||
let etag: String?
|
let etag: String?
|
||||||
|
/** When > 0, next occurrence is this many minutes after trigger (dev/testing). Nil/0 = 24h. Persisted for rollover and recovery. */
|
||||||
|
var rolloverIntervalMinutes: Int?
|
||||||
|
|
||||||
// Phase 2: Delivery tracking properties
|
// Phase 2: Delivery tracking properties
|
||||||
var deliveryStatus: String? // e.g., "scheduled", "delivered", "missed", "error"
|
var deliveryStatus: String? // e.g., "scheduled", "delivered", "missed", "error"
|
||||||
@@ -43,6 +45,7 @@ class NotificationContent: Codable {
|
|||||||
case url
|
case url
|
||||||
case payload
|
case payload
|
||||||
case etag
|
case etag
|
||||||
|
case rolloverIntervalMinutes
|
||||||
case deliveryStatus
|
case deliveryStatus
|
||||||
case lastDeliveryAttempt
|
case lastDeliveryAttempt
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ class NotificationContent: Codable {
|
|||||||
payload = nil
|
payload = nil
|
||||||
}
|
}
|
||||||
etag = try container.decodeIfPresent(String.self, forKey: .etag)
|
etag = try container.decodeIfPresent(String.self, forKey: .etag)
|
||||||
|
rolloverIntervalMinutes = try container.decodeIfPresent(Int.self, forKey: .rolloverIntervalMinutes)
|
||||||
deliveryStatus = try container.decodeIfPresent(String.self, forKey: .deliveryStatus)
|
deliveryStatus = try container.decodeIfPresent(String.self, forKey: .deliveryStatus)
|
||||||
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
|
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,7 @@ class NotificationContent: Codable {
|
|||||||
try container.encode(payloadString, forKey: .payload)
|
try container.encode(payloadString, forKey: .payload)
|
||||||
}
|
}
|
||||||
try container.encodeIfPresent(etag, forKey: .etag)
|
try container.encodeIfPresent(etag, forKey: .etag)
|
||||||
|
try container.encodeIfPresent(rolloverIntervalMinutes, forKey: .rolloverIntervalMinutes)
|
||||||
try container.encodeIfPresent(deliveryStatus, forKey: .deliveryStatus)
|
try container.encodeIfPresent(deliveryStatus, forKey: .deliveryStatus)
|
||||||
try container.encodeIfPresent(lastDeliveryAttempt, forKey: .lastDeliveryAttempt)
|
try container.encodeIfPresent(lastDeliveryAttempt, forKey: .lastDeliveryAttempt)
|
||||||
}
|
}
|
||||||
@@ -100,6 +105,7 @@ class NotificationContent: Codable {
|
|||||||
* @param url URL for content fetching
|
* @param url URL for content fetching
|
||||||
* @param payload Additional payload data
|
* @param payload Additional payload data
|
||||||
* @param etag ETag for HTTP caching
|
* @param etag ETag for HTTP caching
|
||||||
|
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Nil/0 = 24h.
|
||||||
* @param deliveryStatus Delivery status (optional, Phase 2)
|
* @param deliveryStatus Delivery status (optional, Phase 2)
|
||||||
* @param lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
|
* @param lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
|
||||||
*/
|
*/
|
||||||
@@ -111,9 +117,9 @@ class NotificationContent: Codable {
|
|||||||
url: String?,
|
url: String?,
|
||||||
payload: [String: Any]?,
|
payload: [String: Any]?,
|
||||||
etag: String?,
|
etag: String?,
|
||||||
|
rolloverIntervalMinutes: Int? = nil,
|
||||||
deliveryStatus: String? = nil,
|
deliveryStatus: String? = nil,
|
||||||
lastDeliveryAttempt: Int64? = nil) {
|
lastDeliveryAttempt: Int64? = nil) {
|
||||||
|
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -122,6 +128,7 @@ class NotificationContent: Codable {
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.etag = etag
|
self.etag = etag
|
||||||
|
self.rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||||
self.deliveryStatus = deliveryStatus
|
self.deliveryStatus = deliveryStatus
|
||||||
self.lastDeliveryAttempt = lastDeliveryAttempt
|
self.lastDeliveryAttempt = lastDeliveryAttempt
|
||||||
}
|
}
|
||||||
@@ -259,6 +266,7 @@ class NotificationContent: Codable {
|
|||||||
lastDeliveryAttempt = nil
|
lastDeliveryAttempt = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rollover = (dict["rolloverIntervalMinutes"] as? NSNumber)?.intValue
|
||||||
return NotificationContent(
|
return NotificationContent(
|
||||||
id: id,
|
id: id,
|
||||||
title: dict["title"] as? String,
|
title: dict["title"] as? String,
|
||||||
@@ -268,6 +276,7 @@ class NotificationContent: Codable {
|
|||||||
url: dict["url"] as? String,
|
url: dict["url"] as? String,
|
||||||
payload: dict["payload"] as? [String: Any],
|
payload: dict["payload"] as? [String: Any],
|
||||||
etag: dict["etag"] as? String,
|
etag: dict["etag"] as? String,
|
||||||
|
rolloverIntervalMinutes: rollover,
|
||||||
deliveryStatus: dict["deliveryStatus"] as? String,
|
deliveryStatus: dict["deliveryStatus"] as? String,
|
||||||
lastDeliveryAttempt: lastDeliveryAttempt
|
lastDeliveryAttempt: lastDeliveryAttempt
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export interface NotificationOptions {
|
|||||||
retryInterval?: number;
|
retryInterval?: number;
|
||||||
offlineFallback?: boolean;
|
offlineFallback?: boolean;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Optional rollover interval in minutes for dev/testing.
|
||||||
|
* When set (e.g. 10), the next occurrence is scheduled this many minutes after the current trigger
|
||||||
|
* instead of 24 hours. Persisted with the schedule so it survives reboot. When absent or <= 0, use 24h.
|
||||||
|
*/
|
||||||
|
rolloverIntervalMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user