1 Commits

Author SHA1 Message Date
4565e43479 attempt to stop crashes on Android 6 (but they didn't work) 2026-02-26 20:26:43 -07:00
20 changed files with 144 additions and 252 deletions

View File

@@ -94,6 +94,7 @@ The project includes an automated build script that handles both TypeScript and
```bash
# Build all platforms
# Requires npm & gradle (with Java)
./scripts/build-native.sh
# Build specific platform

View File

@@ -37,6 +37,7 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
@@ -116,6 +117,8 @@ dependencies {
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
// Room annotation processor - use kapt for Kotlin, annotationProcessor for Java
kapt "androidx.room:room-compiler:2.6.1"
annotationProcessor "androidx.room:room-compiler:2.6.1"

View File

@@ -229,7 +229,7 @@ public class DailyNotificationFetcher {
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
java.util.TimeZone.getDefault().getID()
);
entity.priority = mapPriority(content.getPriority());
try {

View File

@@ -35,7 +35,7 @@ import org.json.JSONObject
* Bridges Capacitor calls to native Android functionality
*
* @author Matthew Raymer
* @version 1.3.0
* @version 1.1.0
*/
@CapacitorPlugin(name = "DailyNotification")
open class DailyNotificationPlugin : Plugin() {
@@ -1109,13 +1109,8 @@ 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, rolloverIntervalMinutes=$rolloverIntervalMinutes")
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
// Convert HH:mm time to cron expression (daily at specified time)
val cronExpression = convertTimeToCron(time)
@@ -1142,14 +1137,6 @@ 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,
@@ -1167,8 +1154,7 @@ open class DailyNotificationPlugin : Plugin() {
scheduleId,
config,
time,
::calculateNextRunTime,
rolloverIntervalMinutes
::calculateNextRunTime
)
if (success) {
@@ -1176,12 +1162,12 @@ open class DailyNotificationPlugin : Plugin() {
} else {
call.reject("Daily notification scheduling failed")
}
} catch (e: Exception) {
} catch (e: Throwable) {
Log.e(TAG, "Failed to schedule daily notification", e)
call.reject("Daily notification scheduling failed: ${e.message}")
}
}
} catch (e: Exception) {
} catch (e: Throwable) {
Log.e(TAG, "Schedule daily notification error", e)
call.reject("Daily notification error: ${e.message}")
}
@@ -2664,7 +2650,6 @@ 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(
@@ -2673,8 +2658,7 @@ object ScheduleHelper {
scheduleId: String,
config: UserNotificationConfig,
clockTime: String,
calculateNextRunTime: (String) -> Long,
rolloverIntervalMinutes: Int? = null
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
@@ -2705,15 +2689,14 @@ object ScheduleHelper {
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
// notifications at fire time.
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
// Store schedule in database
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
clockTime = clockTime,
enabled = true,
nextRunAt = nextRunTime,
rolloverIntervalMinutes = rolloverIntervalMinutes
nextRunAt = nextRunTime
)
database.scheduleDao().upsert(schedule)
@@ -2722,7 +2705,7 @@ object ScheduleHelper {
try {
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
scheduleId,
"1.3.0",
"1.2.0",
null,
"daily",
config.title ?: "Daily Notification",
@@ -2746,40 +2729,11 @@ object ScheduleHelper {
}
true
} catch (e: Exception) {
} catch (e: Throwable) {
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
false
}
}
/**
* 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)
@@ -2892,30 +2846,9 @@ 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
*
*
* @param context Application context
* @return true if cancellation was successful
*/

View File

@@ -535,33 +535,8 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
// 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());
}
// Calculate next occurrence using DST-safe ZonedDateTime
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
// Check for existing notification at the same time to prevent duplicates
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
@@ -586,30 +561,36 @@ 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();
}
// 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);
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
} catch (Exception e) {
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
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));
// Calculate cron from current scheduled time (extract hour:minute)
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
}
// Create config for next notification
@@ -636,10 +617,7 @@ 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);
@@ -756,13 +734,13 @@ 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.1",
"1.2.0",
null,
"daily",
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
java.util.TimeZone.getDefault().getID()
);
entity.priority = mapPriorityToInt(content.getPriority());
try {
@@ -793,21 +771,6 @@ 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
*

View File

@@ -47,9 +47,7 @@ data class Schedule(
val nextRunAt: Long? = null,
val jitterMs: Int = 0,
val backoffPolicy: String = "exp",
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
val stateJson: String? = null
)
@Entity(tableName = "callbacks")
@@ -85,7 +83,7 @@ data class History(
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 3, // 3: add rollover_interval_minutes to schedules
version = 2, // Incremented for unified schema
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -120,7 +118,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // 1->2: unified; 2->3: rollover_interval_minutes
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
.addCallback(roomCallback)
.build()
INSTANCE = instance
@@ -268,15 +266,6 @@ 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")
}
}
}
}

View File

@@ -17,7 +17,7 @@ import org.json.JSONObject
* Implements exponential backoff and network constraints
*
* @author Matthew Raymer
* @version 1.3.0
* @version 1.2.0
*/
class FetchWorker(
appContext: Context,
@@ -205,13 +205,13 @@ class FetchWorker(
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.3.0", // Plugin version
"1.2.0", // Plugin version
null, // timesafariDid - can be set if available
"daily",
title,
body,
notificationTime,
java.time.ZoneId.systemDefault().id
java.util.TimeZone.getDefault().id
)
entity.priority = 0 // default priority
entity.vibrationEnabled = true
@@ -301,7 +301,7 @@ class FetchWorker(
"timestamp": ${System.currentTimeMillis()},
"content": "Daily notification content",
"source": "mock_generator",
"version": "1.3.0"
"version": "1.2.0"
}
""".trimIndent()
return mockData.toByteArray()

View File

@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
* Implements TTL-at-fire logic and notification delivery
*
* @author Matthew Raymer
* @version 1.3.0
* @version 1.2.0
*/
/**
* Source of schedule request - tracks which code path triggered scheduling
@@ -251,13 +251,13 @@ class NotifyReceiver : BroadcastReceiver() {
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.3.0", // Plugin version
"1.2.0", // Plugin version
null, // timesafariDid - can be set if available
"daily",
config.title,
config.body ?: (if (contentCache != null) String(contentCache.payload) else ""),
triggerAtMillis,
java.time.ZoneId.systemDefault().id
java.util.TimeZone.getDefault().id
)
entity.priority = when (config.priority) {
"high", "max" -> 2
@@ -275,7 +275,7 @@ class NotifyReceiver : BroadcastReceiver() {
roomStorage.saveNotificationContent(entity).get()
Log.d(TAG, "Stored notification content in database: id=$notificationId (for recovery tracking)")
}
} catch (e: Exception) {
} catch (e: Throwable) {
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
}
@@ -400,14 +400,18 @@ class NotifyReceiver : BroadcastReceiver() {
Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
} catch (e: SecurityException) {
} catch (e: Throwable) {
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
try {
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} catch (fallbackError: Throwable) {
Log.e(TAG, "Fallback alarm scheduling also failed", fallbackError)
}
}
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
@@ -462,8 +466,7 @@ class NotifyReceiver : BroadcastReceiver() {
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
}
}
} catch (e: Exception) {
// Log but don't fail - alarm is already scheduled, DB update is best-effort
} catch (e: Throwable) {
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
}
}

View File

@@ -125,8 +125,8 @@ class ReactivationManager(private val context: Context) {
markMissedNotificationForSchedule(schedule, nextRunTime, db)
missedCount++
// Schedule next occurrence (use rollover interval if set, else 24h)
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
// Schedule next occurrence if repeating
val nextOccurrence = calculateNextOccurrence(currentTime)
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
rescheduledCount++
@@ -238,25 +238,10 @@ 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,
@@ -282,13 +267,13 @@ class ReactivationManager(private val context: Context) {
// Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.3.0", // Plugin version
"1.2.0", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
"Your daily update is ready",
scheduledTime,
java.time.ZoneId.systemDefault().id
java.util.TimeZone.getDefault().id
)
notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = System.currentTimeMillis()
@@ -1052,13 +1037,13 @@ class ReactivationManager(private val context: Context) {
// Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.3.0", // Plugin version
"1.2.0", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
"Your daily update is ready",
scheduledTime,
java.time.ZoneId.systemDefault().id
java.util.TimeZone.getDefault().id
)
notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = System.currentTimeMillis()

View File

@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
private final ExecutorService executorService;
// Plugin version for migration tracking
private static final String PLUGIN_VERSION = "1.2.1";
private static final String PLUGIN_VERSION = "1.2.0";
/**
* Constructor

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'DailyNotificationPlugin'
s.version = '1.2.1'
s.version = '1.2.0'
s.summary = 'Daily Notification Plugin for Capacitor'
s.license = 'MIT'
s.homepage = 'https://github.com/timesafari/daily-notification-plugin'

View File

@@ -1118,14 +1118,12 @@ 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 (persist rollover interval for dev/testing; survives app restart)
// Create notification content
let content = NotificationContent(
id: "daily_\(Date().timeIntervalSince1970)",
title: title,
@@ -1134,8 +1132,7 @@ public class DailyNotificationPlugin: CAPPlugin {
fetchedAt: fetchedAt,
url: url,
payload: nil,
etag: nil,
rolloverIntervalMinutes: rolloverIntervalMinutes
etag: nil
)
// Delegate to ScheduleHelper for orchestration

View File

@@ -382,24 +382,55 @@ class DailyNotificationScheduler {
}
/**
* 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.
* 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)`
*/
func calculateNextScheduledTime(_ currentScheduledTime: Int64, rolloverIntervalMinutes: Int? = nil) -> Int64 {
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
let calendar = Calendar.current
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
let currentTimeStr = formatTime(currentScheduledTime)
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)")
// 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)")
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)) rollover_min=\(rolloverIntervalMinutes ?? 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))")
return nextTime
}
@@ -442,16 +473,16 @@ class DailyNotificationScheduler {
}
}
// 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)
// Calculate next occurrence using DST-safe calculation
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
// 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")
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime, rolloverIntervalMinutes: rolloverMin)
print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime)
}
let nextScheduledTimeStr = formatTime(nextScheduledTime)
@@ -508,14 +539,13 @@ class DailyNotificationScheduler {
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
let nextContent = NotificationContent(
id: nextId,
title: content.title,
body: content.body,
title: content.title, // Will be updated by prefetch
body: content.body, // Will be updated by prefetch
scheduledTime: nextScheduledTime,
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: content.url,
payload: content.payload,
etag: content.etag,
rolloverIntervalMinutes: content.rolloverIntervalMinutes
etag: content.etag
)
// Schedule the next notification

View File

@@ -27,8 +27,6 @@ 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"
@@ -45,7 +43,6 @@ class NotificationContent: Codable {
case url
case payload
case etag
case rolloverIntervalMinutes
case deliveryStatus
case lastDeliveryAttempt
}
@@ -67,7 +64,6 @@ 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)
}
@@ -87,7 +83,6 @@ 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)
}
@@ -105,21 +100,20 @@ 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)
*/
init(id: String,
title: String?,
body: String?,
scheduledTime: Int64,
fetchedAt: Int64,
url: String?,
payload: [String: Any]?,
init(id: String,
title: String?,
body: String?,
scheduledTime: Int64,
fetchedAt: Int64,
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
@@ -128,7 +122,6 @@ class NotificationContent: Codable {
self.url = url
self.payload = payload
self.etag = etag
self.rolloverIntervalMinutes = rolloverIntervalMinutes
self.deliveryStatus = deliveryStatus
self.lastDeliveryAttempt = lastDeliveryAttempt
}
@@ -266,7 +259,6 @@ class NotificationContent: Codable {
lastDeliveryAttempt = nil
}
let rollover = (dict["rolloverIntervalMinutes"] as? NSNumber)?.intValue
return NotificationContent(
id: id,
title: dict["title"] as? String,
@@ -276,7 +268,6 @@ 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
)

View File

@@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "1.3.0",
"version": "1.2.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",

View File

@@ -5,7 +5,7 @@
* Aligned with Android implementation and test requirements
*
* @author Matthew Raymer
* @version 1.3.0 (see package.json for source of truth)
* @version 1.2.0 (see package.json for source of truth)
*/
// Import SPI types from content-fetcher.ts
@@ -82,12 +82,6 @@ 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;
}

View File

@@ -3,7 +3,7 @@
* Provides structured logging, event codes, and health monitoring
*
* @author Matthew Raymer
* @version 1.3.0
* @version 1.2.0
*/
import {

View File

@@ -7,7 +7,7 @@
* This implementation provides clear error messages for all methods.
*
* @author Matthew Raymer
* @version 1.3.0
* @version 1.2.0
*/
import type {

View File

@@ -65,6 +65,7 @@ emulator -avd AVD_NAME
adb devices
# Now install on the emulator
# ... which can take a looooooong time
adb install -r ./app/build/outputs/apk/debug/app-debug.apk
# Now start the app

View File

@@ -17,6 +17,7 @@ android {
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
@@ -62,6 +63,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"