Compare commits
1 Commits
rollover-i
...
android-6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4565e43479 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides structured logging, event codes, and health monitoring
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.3.0
|
||||
* @version 1.2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user