fix(android): reset alarm and static reminder rollover; add cancelDailyReminder
Fixes two integration bugs with the consuming app (Time Safari) and adds
Android parity for cancel-by-id.
Problem:
- Re-setting a daily notification (edit/save same time) could cancel the
alarm then skip re-scheduling because DB idempotence still ran and
treated the update as a duplicate.
- After the first fire, rollover scheduled the next run with
isStaticReminder=false, so title/body reverted to fallback.
- App calls cancelDailyReminder({ reminderId }) but Android had no
implementation (only cancelAllNotifications and scheduleDailyReminder).
Changes:
- NotifyReceiver.kt: Run DB idempotence only when
!skipPendingIntentIdempotence. When true (e.g. app reset flow), skip
the check and log; prevents "no alarm" after cancel-then-schedule.
- DailyNotificationWorker.java: In scheduleNextNotification(), read
is_static_reminder from WorkManager input; keep stable scheduleId for
static reminders; pass preserveStaticReminder and reminderId into
scheduleExactNotification(); add DN|ROLLOVER log.
- DailyNotificationPlugin.kt: Add cancelDailyReminder(call) that parses
reminderId (or id, reminder_id, scheduleId), calls
NotifyReceiver.cancelNotification(context, scheduleId), and does
best-effort DB cleanup (setEnabled false, updateRunTimes null).
Files modified:
- android/.../NotifyReceiver.kt
- android/.../DailyNotificationWorker.java
- android/.../DailyNotificationPlugin.kt
This commit is contained in:
@@ -706,6 +706,34 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleDailyNotification(call)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelDailyReminder(call: PluginCall) {
|
||||
try {
|
||||
val reminderId = call.getString("reminderId")
|
||||
?: call.getString("id")
|
||||
?: call.getString("reminder_id")
|
||||
?: call.getString("scheduleId")
|
||||
if (reminderId.isNullOrBlank()) {
|
||||
call.reject("cancelDailyReminder: missing reminderId")
|
||||
return
|
||||
}
|
||||
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val db = getDatabase()
|
||||
db.scheduleDao().setEnabled(reminderId, false)
|
||||
db.scheduleDao().updateRunTimes(reminderId, null, null)
|
||||
}
|
||||
} catch (dbErr: Exception) {
|
||||
Log.w(TAG, "cancelDailyReminder: failed DB update for $reminderId", dbErr)
|
||||
}
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelDailyReminder failed", e)
|
||||
call.reject("cancelDailyReminder failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exact alarms can be scheduled
|
||||
* Helper method for internal use
|
||||
|
||||
@@ -540,15 +540,19 @@ public class DailyNotificationWorker extends Worker {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve static reminder semantics across rollover so title/body don't revert to fallback
|
||||
Data inputData = getInputData();
|
||||
boolean preserveStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
||||
|
||||
// Extract scheduleId from notificationId pattern or use fallback
|
||||
// Notification IDs are often "daily_${scheduleId}"
|
||||
// For static reminders, keep stable scheduleId across days
|
||||
String scheduleId = null;
|
||||
String cronExpression = null;
|
||||
|
||||
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
|
||||
String notificationId = content.getId();
|
||||
if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId; // Use notificationId as scheduleId
|
||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||
scheduleId = notificationId;
|
||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId;
|
||||
} else {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
@@ -581,12 +585,13 @@ public class DailyNotificationWorker extends Worker {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
getApplicationContext(),
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
preserveStaticReminder, // isStaticReminder – preserve so next run keeps title/body
|
||||
preserveStaticReminder ? scheduleId : null, // reminderId
|
||||
scheduleId,
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
|
||||
@@ -204,26 +204,31 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||
// This prevents logical duplicates before even hitting AlarmManager
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
||||
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
||||
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
||||
if (timeDiff < 60000) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
||||
return@runBlocking
|
||||
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
|
||||
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
||||
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
||||
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
||||
if (timeDiff < 60000) {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
||||
return@runBlocking
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
|
||||
}
|
||||
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
|
||||
Reference in New Issue
Block a user