Compare commits
4 Commits
android-6
...
rollover-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2714480070 | ||
|
|
e873a46bbd | ||
|
|
aa0eaa5389 | ||
|
|
c36781e440 |
@@ -35,7 +35,7 @@ import org.json.JSONObject
|
||||
* Bridges Capacitor calls to native Android functionality
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 1.3.0
|
||||
*/
|
||||
@CapacitorPlugin(name = "DailyNotification")
|
||||
open class DailyNotificationPlugin : Plugin() {
|
||||
@@ -1109,8 +1109,13 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val sound = options.getBoolean("sound") ?: true
|
||||
val priority = options.getString("priority") ?: "default"
|
||||
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)
|
||||
val cronExpression = convertTimeToCron(time)
|
||||
@@ -1137,6 +1142,14 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
|
||||
}
|
||||
|
||||
// Cancel only fetch-related WorkManager jobs so they cannot create a second (UUID) alarm
|
||||
// with fallback or placeholder text. Does not cancel display/dismiss; future fetched-content
|
||||
// flows should use distinct tags so they are not affected.
|
||||
val workCancelled = ScheduleHelper.cancelFetchRelatedWorkManagerJobs(context)
|
||||
if (workCancelled) {
|
||||
Log.i(TAG, "scheduleDailyNotification: Cancelled pending prefetch/fetch WorkManager jobs")
|
||||
}
|
||||
|
||||
val config = UserNotificationConfig(
|
||||
enabled = true,
|
||||
schedule = cronExpression,
|
||||
@@ -1154,7 +1167,8 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleId,
|
||||
config,
|
||||
time,
|
||||
::calculateNextRunTime
|
||||
::calculateNextRunTime,
|
||||
rolloverIntervalMinutes
|
||||
)
|
||||
|
||||
if (success) {
|
||||
@@ -2650,6 +2664,7 @@ object ScheduleHelper {
|
||||
* @param config User notification configuration
|
||||
* @param clockTime Original HH:mm time string
|
||||
* @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
|
||||
*/
|
||||
suspend fun scheduleDailyNotification(
|
||||
@@ -2658,7 +2673,8 @@ object ScheduleHelper {
|
||||
scheduleId: String,
|
||||
config: UserNotificationConfig,
|
||||
clockTime: String,
|
||||
calculateNextRunTime: (String) -> Long
|
||||
calculateNextRunTime: (String) -> Long,
|
||||
rolloverIntervalMinutes: Int? = null
|
||||
): Boolean {
|
||||
return try {
|
||||
val nextRunTime = calculateNextRunTime(config.schedule)
|
||||
@@ -2689,14 +2705,15 @@ object ScheduleHelper {
|
||||
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
||||
// notifications at fire time.
|
||||
|
||||
// Store schedule in database
|
||||
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
|
||||
val schedule = Schedule(
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = config.schedule,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
nextRunAt = nextRunTime
|
||||
nextRunAt = nextRunTime,
|
||||
rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||
)
|
||||
database.scheduleDao().upsert(schedule)
|
||||
|
||||
@@ -2705,7 +2722,7 @@ object ScheduleHelper {
|
||||
try {
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
scheduleId,
|
||||
"1.2.0",
|
||||
"1.3.0",
|
||||
null,
|
||||
"daily",
|
||||
config.title ?: "Daily Notification",
|
||||
@@ -2735,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)
|
||||
*
|
||||
@@ -2846,6 +2892,27 @@ object ScheduleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
|
||||
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
|
||||
* Use this when handling scheduleDailyNotification so pending prefetch does not run and create
|
||||
* a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if cancellation was successful
|
||||
*/
|
||||
suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
|
||||
return try {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWorkByTag("prefetch")
|
||||
workManager.cancelAllWorkByTag("daily_notification_fetch")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all WorkManager jobs by tags
|
||||
*
|
||||
|
||||
@@ -535,8 +535,33 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
|
||||
|
||||
// Calculate next occurrence using DST-safe ZonedDateTime
|
||||
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
// Resolve schedule_id first so we can load rollover interval from DB
|
||||
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
|
||||
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
|
||||
@@ -561,36 +586,30 @@ public class DailyNotificationWorker extends Worker {
|
||||
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 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)
|
||||
if (rolloverMinutes == null || rolloverMinutes <= 0) {
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(content.getScheduledTime());
|
||||
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
||||
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);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
||||
cronExpression = "0 9 * * *"; // Default to 9 AM
|
||||
cronExpression = "0 9 * * *";
|
||||
}
|
||||
} 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
|
||||
@@ -617,7 +636,10 @@ public class DailyNotificationWorker extends Worker {
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
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
|
||||
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
||||
@@ -734,7 +756,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
NotificationContentEntity entity = new NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.2.0",
|
||||
"1.2.1",
|
||||
null,
|
||||
"daily",
|
||||
content.getTitle(),
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -47,7 +47,9 @@ data class Schedule(
|
||||
val nextRunAt: Long? = null,
|
||||
val jitterMs: Int = 0,
|
||||
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")
|
||||
@@ -83,7 +85,7 @@ data class History(
|
||||
NotificationDeliveryEntity::class,
|
||||
NotificationConfigEntity::class
|
||||
],
|
||||
version = 2, // Incremented for unified schema
|
||||
version = 3, // 3: add rollover_interval_minutes to schedules
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -118,7 +120,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
DailyNotificationDatabase::class.java,
|
||||
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)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
@@ -266,6 +268,15 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
""".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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.json.JSONObject
|
||||
* Implements exponential backoff and network constraints
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.2.0
|
||||
* @version 1.3.0
|
||||
*/
|
||||
class FetchWorker(
|
||||
appContext: Context,
|
||||
@@ -205,7 +205,7 @@ class FetchWorker(
|
||||
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.2.0", // Plugin version
|
||||
"1.3.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
title,
|
||||
@@ -301,7 +301,7 @@ class FetchWorker(
|
||||
"timestamp": ${System.currentTimeMillis()},
|
||||
"content": "Daily notification content",
|
||||
"source": "mock_generator",
|
||||
"version": "1.2.0"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
""".trimIndent()
|
||||
return mockData.toByteArray()
|
||||
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
|
||||
* Implements TTL-at-fire logic and notification delivery
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.2.0
|
||||
* @version 1.3.0
|
||||
*/
|
||||
/**
|
||||
* Source of schedule request - tracks which code path triggered scheduling
|
||||
@@ -251,7 +251,7 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.2.0", // Plugin version
|
||||
"1.3.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
|
||||
@@ -125,8 +125,8 @@ class ReactivationManager(private val context: Context) {
|
||||
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||
missedCount++
|
||||
|
||||
// Schedule next occurrence if repeating
|
||||
val nextOccurrence = calculateNextOccurrence(currentTime)
|
||||
// Schedule next occurrence (use rollover interval if set, else 24h)
|
||||
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
|
||||
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
||||
rescheduledCount++
|
||||
|
||||
@@ -238,11 +238,26 @@ class ReactivationManager(private val context: Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
schedule: Schedule,
|
||||
scheduledTime: Long,
|
||||
@@ -267,7 +282,7 @@ class ReactivationManager(private val context: Context) {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.2.0", // Plugin version
|
||||
"1.3.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -1037,7 +1052,7 @@ class ReactivationManager(private val context: Context) {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.2.0", // Plugin version
|
||||
"1.3.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
|
||||
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
|
||||
private final ExecutorService executorService;
|
||||
|
||||
// Plugin version for migration tracking
|
||||
private static final String PLUGIN_VERSION = "1.2.0";
|
||||
private static final String PLUGIN_VERSION = "1.2.1";
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'DailyNotificationPlugin'
|
||||
s.version = '1.2.0'
|
||||
s.version = '1.2.1'
|
||||
s.summary = 'Daily Notification Plugin for Capacitor'
|
||||
s.license = 'MIT'
|
||||
s.homepage = 'https://github.com/timesafari/daily-notification-plugin'
|
||||
|
||||
@@ -1118,12 +1118,14 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
let sound = call.getBool("sound", true)
|
||||
let url = call.getString("url")
|
||||
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)
|
||||
let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute)
|
||||
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(
|
||||
id: "daily_\(Date().timeIntervalSince1970)",
|
||||
title: title,
|
||||
@@ -1132,7 +1134,8 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
fetchedAt: fetchedAt,
|
||||
url: url,
|
||||
payload: nil,
|
||||
etag: nil
|
||||
etag: nil,
|
||||
rolloverIntervalMinutes: rolloverIntervalMinutes
|
||||
)
|
||||
|
||||
// Delegate to ScheduleHelper for orchestration
|
||||
|
||||
@@ -382,55 +382,24 @@ class DailyNotificationScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next scheduled time from current scheduled time (24 hours later, DST-safe)
|
||||
*
|
||||
* 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)`
|
||||
* 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.
|
||||
*/
|
||||
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
|
||||
func calculateNextScheduledTime(_ currentScheduledTime: Int64, rolloverIntervalMinutes: Int? = nil) -> Int64 {
|
||||
let calendar = Calendar.current
|
||||
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
|
||||
let currentTimeStr = formatTime(currentScheduledTime)
|
||||
|
||||
// Add 24 hours (handles DST transitions automatically)
|
||||
// TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing
|
||||
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
||||
// 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)")
|
||||
let addMinutes = (rolloverIntervalMinutes ?? 0) > 0 ? rolloverIntervalMinutes! : (24 * 60)
|
||||
guard let nextDate = calendar.date(byAdding: .minute, value: addMinutes, to: currentDate) else {
|
||||
let fallbackTime = currentScheduledTime + (Int64(addMinutes) * 60 * 1000)
|
||||
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback add_minutes=\(addMinutes)")
|
||||
return fallbackTime
|
||||
}
|
||||
|
||||
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||
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 timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
||||
NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
||||
print("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)")
|
||||
return nextTime
|
||||
}
|
||||
|
||||
@@ -473,16 +442,16 @@ class DailyNotificationScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next occurrence using DST-safe calculation
|
||||
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
|
||||
// Calculate next occurrence (use stored rollover interval for dev/testing, else 24h)
|
||||
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
|
||||
// This handles cases where the notification fired more than 2 minutes ago
|
||||
while nextScheduledTime < currentTime {
|
||||
let nextTimeStr = formatTime(nextScheduledTime)
|
||||
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)
|
||||
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime, rolloverIntervalMinutes: rolloverMin)
|
||||
}
|
||||
|
||||
let nextScheduledTimeStr = formatTime(nextScheduledTime)
|
||||
@@ -539,13 +508,14 @@ class DailyNotificationScheduler {
|
||||
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
||||
let nextContent = NotificationContent(
|
||||
id: nextId,
|
||||
title: content.title, // Will be updated by prefetch
|
||||
body: content.body, // Will be updated by prefetch
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
scheduledTime: nextScheduledTime,
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
url: content.url,
|
||||
payload: content.payload,
|
||||
etag: content.etag
|
||||
etag: content.etag,
|
||||
rolloverIntervalMinutes: content.rolloverIntervalMinutes
|
||||
)
|
||||
|
||||
// Schedule the next notification
|
||||
|
||||
@@ -27,6 +27,8 @@ class NotificationContent: Codable {
|
||||
let url: String?
|
||||
let payload: [String: Any]?
|
||||
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
|
||||
var deliveryStatus: String? // e.g., "scheduled", "delivered", "missed", "error"
|
||||
@@ -43,6 +45,7 @@ class NotificationContent: Codable {
|
||||
case url
|
||||
case payload
|
||||
case etag
|
||||
case rolloverIntervalMinutes
|
||||
case deliveryStatus
|
||||
case lastDeliveryAttempt
|
||||
}
|
||||
@@ -64,6 +67,7 @@ class NotificationContent: Codable {
|
||||
payload = nil
|
||||
}
|
||||
etag = try container.decodeIfPresent(String.self, forKey: .etag)
|
||||
rolloverIntervalMinutes = try container.decodeIfPresent(Int.self, forKey: .rolloverIntervalMinutes)
|
||||
deliveryStatus = try container.decodeIfPresent(String.self, forKey: .deliveryStatus)
|
||||
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
|
||||
}
|
||||
@@ -83,6 +87,7 @@ class NotificationContent: Codable {
|
||||
try container.encode(payloadString, forKey: .payload)
|
||||
}
|
||||
try container.encodeIfPresent(etag, forKey: .etag)
|
||||
try container.encodeIfPresent(rolloverIntervalMinutes, forKey: .rolloverIntervalMinutes)
|
||||
try container.encodeIfPresent(deliveryStatus, forKey: .deliveryStatus)
|
||||
try container.encodeIfPresent(lastDeliveryAttempt, forKey: .lastDeliveryAttempt)
|
||||
}
|
||||
@@ -100,6 +105,7 @@ class NotificationContent: Codable {
|
||||
* @param url URL for content fetching
|
||||
* @param payload Additional payload data
|
||||
* @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 lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
|
||||
*/
|
||||
@@ -111,9 +117,9 @@ class NotificationContent: Codable {
|
||||
url: String?,
|
||||
payload: [String: Any]?,
|
||||
etag: String?,
|
||||
rolloverIntervalMinutes: Int? = nil,
|
||||
deliveryStatus: String? = nil,
|
||||
lastDeliveryAttempt: Int64? = nil) {
|
||||
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
@@ -122,6 +128,7 @@ class NotificationContent: Codable {
|
||||
self.url = url
|
||||
self.payload = payload
|
||||
self.etag = etag
|
||||
self.rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||
self.deliveryStatus = deliveryStatus
|
||||
self.lastDeliveryAttempt = lastDeliveryAttempt
|
||||
}
|
||||
@@ -259,6 +266,7 @@ class NotificationContent: Codable {
|
||||
lastDeliveryAttempt = nil
|
||||
}
|
||||
|
||||
let rollover = (dict["rolloverIntervalMinutes"] as? NSNumber)?.intValue
|
||||
return NotificationContent(
|
||||
id: id,
|
||||
title: dict["title"] as? String,
|
||||
@@ -268,6 +276,7 @@ class NotificationContent: Codable {
|
||||
url: dict["url"] as? String,
|
||||
payload: dict["payload"] as? [String: Any],
|
||||
etag: dict["etag"] as? String,
|
||||
rolloverIntervalMinutes: rollover,
|
||||
deliveryStatus: dict["deliveryStatus"] as? String,
|
||||
lastDeliveryAttempt: lastDeliveryAttempt
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@timesafari/daily-notification-plugin",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
|
||||
"main": "dist/plugin.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Aligned with Android implementation and test requirements
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.2.0 (see package.json for source of truth)
|
||||
* @version 1.3.0 (see package.json for source of truth)
|
||||
*/
|
||||
|
||||
// Import SPI types from content-fetcher.ts
|
||||
@@ -82,6 +82,12 @@ export interface NotificationOptions {
|
||||
retryInterval?: number;
|
||||
offlineFallback?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides structured logging, event codes, and health monitoring
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.2.0
|
||||
* @version 1.3.0
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* This implementation provides clear error messages for all methods.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.2.0
|
||||
* @version 1.3.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
||||
Reference in New Issue
Block a user