Fix: Android daily notification: single schedule on edit, no double-cancel

Resolves long-standing issue where the second scheduled time (after editing
the reminder) did not fire on Android.

- PushNotificationPermission: add open(..., options?: { skipSchedule }).
  When skipSchedule is true (edit flow), dialog only invokes callback with
  time/message; parent is sole scheduler so the plugin is not called twice.
- AccountViewView: pass { skipSchedule: true } when opening the dialog for
  edit; keep cancel (iOS only) + single scheduleDailyNotification in callback.
- NativeNotificationService: serialize scheduleDailyNotification so only one
  schedule runs at a time (scheduleLock + doScheduleDailyNotification).
- AccountViewView: guard edit-reminder callback with editReminderScheduleInProgress
  so one schedule per user action.
- Gate pre-cancel on Android in edit flow (CONSUMING_APP brief): skip
  cancelDailyNotification before schedule on Android; plugin cancels internally.
- Use single stable reminder id and always pass id on both platforms (plugin 1.1.2+).
- Add doc/plugin-android-edit-reschedule-alarm-not-firing.md for plugin repo
  (cancel-before-reschedule may cancel the PendingIntent used for setAlarmClock).
This commit is contained in:
Jose Olarte III
2026-02-16 21:25:07 +08:00
parent dc3f12d53b
commit 0e096b1a46
6 changed files with 197 additions and 10 deletions

View File

@@ -156,6 +156,8 @@ export default class PushNotificationPermission extends Vue {
messageInput = "";
minuteInput = "00";
pushType = "";
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
skipScheduleForOpen = false;
serviceWorkerReady = false;
vapidKey = "";
@@ -169,10 +171,12 @@ export default class PushNotificationPermission extends Vue {
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
options?: { skipSchedule?: boolean },
) {
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
this.skipScheduleForOpen = options?.skipSchedule ?? false;
// Native platforms: Skip web push initialization
if (this.isNativePlatform) {
@@ -689,6 +693,12 @@ export default class PushNotificationPermission extends Vue {
"[PushNotificationPermission] Starting native notification setup",
);
// Edit flow: parent will cancel + schedule; avoid double schedule (second call cancels alarm first set).
if (this.skipScheduleForOpen) {
this.callback(true, this.notificationTimeText, this.messageInput);
return;
}
// Import and check plugin availability before using service
const { DailyNotification } = await import(
"@/plugins/DailyNotificationPlugin"

View File

@@ -49,6 +49,12 @@ export class NativeNotificationService implements NotificationServiceInterface {
*/
private readonly reminderId = "daily_timesafari_reminder";
/**
* Ensures only one scheduleDailyNotification runs at a time (no rapid successive plugin calls).
* Each new call waits for the previous to complete before running.
*/
private scheduleLock: Promise<boolean> = Promise.resolve(true);
/**
* Native notifications are always supported on iOS/Android
*/
@@ -235,10 +241,23 @@ export class NativeNotificationService implements NotificationServiceInterface {
}
/**
* Schedule a daily notification using native alarms
* Schedule a daily notification using native alarms.
* Serialized so only one schedule runs at a time (avoids rapid successive plugin calls on Android).
*/
async scheduleDailyNotification(
options: DailyNotificationOptions,
): Promise<boolean> {
const run = (): Promise<boolean> =>
this.doScheduleDailyNotification(options);
this.scheduleLock = this.scheduleLock.then(() => run());
return this.scheduleLock;
}
/**
* Internal implementation of schedule; called under scheduleLock.
*/
private async doScheduleDailyNotification(
options: DailyNotificationOptions,
): Promise<boolean> {
try {
logger.info(

View File

@@ -824,6 +824,7 @@ interface PushNotificationPermissionRef {
open: (
title: string,
callback: (success: boolean, timeText: string, message?: string) => void,
options?: { skipSchedule?: boolean },
) => void;
hourInput?: string;
minuteInput?: string;
@@ -896,6 +897,8 @@ export default class AccountViewView extends Vue {
notifyingReminder: boolean = false;
notifyingReminderMessage: string = "";
notifyingReminderTime: string = "";
/** Guard: only one edit-reminder schedule per user action (avoids double schedule on Android). */
editReminderScheduleInProgress: boolean = false;
subscription: PushSubscription | null = null;
// UI state properties
@@ -1293,16 +1296,21 @@ export default class AccountViewView extends Vue {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
// Open the dialog - it will use the same callback pattern as showReminderNotificationChoice
// skipSchedule: true so only this callback schedules (dialog does not). Avoids double schedule on Android.
dialog.open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (success) {
// Cancel the old notification before scheduling the new one
if (!success) return;
if (this.editReminderScheduleInProgress) return;
this.editReminderScheduleInProgress = true;
try {
const service = NotificationService.getInstance();
await service.cancelDailyNotification();
// On iOS: cancel then schedule. On Android: plugin cancels internally when scheduling with same id; skip pre-cancel to avoid double-cancel edge cases.
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
// Schedule the updated notification
// Schedule the updated notification (one call per user action)
const time24h = this.parseTimeTo24Hour(timeText);
const title = "Daily Reminder";
const body =
@@ -1325,8 +1333,11 @@ export default class AccountViewView extends Vue {
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
}
} finally {
this.editReminderScheduleInProgress = false;
}
},
{ skipSchedule: true },
);
// Pre-populate the form with current values after dialog opens