From 80cc09de95e4a83bf41c121bc8ad2da92fe8b7f4 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 2 Feb 2026 06:18:32 -0800 Subject: [PATCH] WIP: Change DailyNotificationReceiver to exported=true for AlarmManager broadcasts The DailyNotificationReceiver was not being triggered when scheduled alarms fired, preventing notifications from appearing at the scheduled time. Changed android:exported from "false" to "true" to allow AlarmManager broadcasts to reach the receiver, especially when the app is closed or the device is in doze mode. This is a work-in-progress change to diagnose why notifications aren't firing. The receiver should log "DN|RECEIVE_START" when triggered, but we were not seeing these logs even when alarms were scheduled. Next steps: - Test if receiver is now triggered when alarm fires - Verify notifications appear at scheduled time - Consider adding permission check if keeping exported=true for security --- android/app/src/main/AndroidManifest.xml | 5 +- .../java/app/timesafari/MainActivity.java | 4 +- src/components/PushNotificationPermission.vue | 77 ++++- .../NativeNotificationService.ts | 314 +++++++++++++++--- 4 files changed, 349 insertions(+), 51 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 83ff8bee..45869c63 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,15 +44,18 @@ + + + android:exported="true"> + = {}; @@ -784,13 +840,22 @@ export default class PushNotificationPermission extends Vue { settingsToSave, ); - // Show success message + // Check if exact alarm permission is needed (Android 12+) + const needsExactAlarmWarning = + Capacitor.getPlatform() === "android" && + !finalPermissionCheck.details.exactAlarm; + + // Show success message with optional warning + const successMessage = needsExactAlarmWarning + ? `${NOTIFY_PUSH_SUCCESS.message} Note: If notifications don't appear, enable "Exact alarms" in Android Settings → Apps → TimeSafari → App settings.` + : NOTIFY_PUSH_SUCCESS.message; + this.$notify( { group: "alert", type: "success", title: NOTIFY_PUSH_SUCCESS.title, - text: NOTIFY_PUSH_SUCCESS.message, + text: successMessage, }, PUSH_NOTIFICATION_TIMEOUT_LONG, ); @@ -802,6 +867,14 @@ export default class PushNotificationPermission extends Vue { "[PushNotificationPermission] Error in native notification setup:", error, ); + // Log additional error details for debugging + if (error instanceof Error) { + logger.error("[PushNotificationPermission] Error details:", { + message: error.message, + stack: error.stack, + name: error.name, + }); + } this.$notify( { group: "alert", diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index 5e271d7a..9b14707e 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -50,6 +50,10 @@ export class NativeNotificationService implements NotificationServiceInterface { return true; } + /** + * Request notification permissions from the OS + * Shows native permission dialog on first call + */ /** * Request notification permissions from the OS * Shows native permission dialog on first call @@ -58,31 +62,132 @@ export class NativeNotificationService implements NotificationServiceInterface { try { logger.debug("[NativeNotificationService] Requesting permissions"); + // Check if plugin is available + if (!DailyNotification) { + logger.error( + "[NativeNotificationService] DailyNotification plugin is not available. " + + "Make sure the plugin is registered in MainActivity.", + ); + return false; + } + + // Check if the method exists + if ( + typeof DailyNotification.requestNotificationPermissions !== "function" + ) { + logger.error( + "[NativeNotificationService] requestNotificationPermissions method not found on plugin. " + + "Available methods: " + + Object.keys(DailyNotification).join(", "), + ); + return false; + } + + logger.debug( + "[NativeNotificationService] Calling requestNotificationPermissions...", + ); + // Use requestNotificationPermissions() which is the method exposed by iOS // For Android, this should also work via the plugin bridge const result = await DailyNotification.requestNotificationPermissions(); - logger.debug("[NativeNotificationService] Permission result:", result); + logger.debug( + "[NativeNotificationService] Permission result:", + JSON.stringify(result, null, 2), + ); + logger.debug( + "[NativeNotificationService] Result type:", + typeof result, + "Keys:", + result ? Object.keys(result) : "null", + ); - // The result may be PermissionStatus (with granted boolean) or PermissionStatusResult - // Handle both formats for compatibility - if ("granted" in result && typeof result.granted === "boolean") { + // The result is PermissionStatus which has: + // - granted?: boolean + // - notifications: PermissionState ("granted" | "denied" | "prompt") + // Handle all possible formats for compatibility + if ( + result && + "granted" in result && + typeof result.granted === "boolean" + ) { + logger.debug( + "[NativeNotificationService] Using 'granted' field:", + result.granted, + ); return result.granted; } + + // Check the notifications PermissionState field + if (result && "notifications" in result) { + const notificationsState = result.notifications; + logger.debug( + "[NativeNotificationService] Notifications state:", + notificationsState, + ); + // PermissionState can be "granted", "denied", or "prompt" + if (notificationsState === "granted") { + return true; + } + if (notificationsState === "denied") { + return false; + } + // If "prompt", the user hasn't decided yet, so return false + if (notificationsState === "prompt") { + logger.warn( + "[NativeNotificationService] Permission still in prompt state after request", + ); + return false; + } + } + + // Check for PermissionStatusResult format (from checkPermissionStatus) if ( + result && "allPermissionsGranted" in result && typeof result.allPermissionsGranted === "boolean" ) { + logger.debug( + "[NativeNotificationService] Using 'allPermissionsGranted' field:", + result.allPermissionsGranted, + ); return result.allPermissionsGranted; } + + // If result is a boolean directly (some plugin versions might return this) + if (typeof result === "boolean") { + logger.debug("[NativeNotificationService] Result is boolean:", result); + return result; + } + // Fallback: check status after requesting + logger.debug( + "[NativeNotificationService] Falling back to checkPermissionStatus...", + ); const status = await DailyNotification.checkPermissionStatus(); + logger.debug( + "[NativeNotificationService] Status check result:", + JSON.stringify(status, null, 2), + ); return status.allPermissionsGranted; } catch (error) { logger.error( "[NativeNotificationService] Permission request failed:", error, ); + // Log additional error details for debugging + if (error instanceof Error) { + logger.error("[NativeNotificationService] Error details:", { + message: error.message, + stack: error.stack, + name: error.name, + }); + } else { + logger.error( + "[NativeNotificationService] Non-Error exception:", + JSON.stringify(error, null, 2), + ); + } return false; } } @@ -94,8 +199,13 @@ export class NativeNotificationService implements NotificationServiceInterface { try { const status = await DailyNotification.checkPermissionStatus(); + // Calculate granted status from individual permissions + const allGranted = + status.allPermissionsGranted || + (status.notificationsEnabled && status.exactAlarmEnabled); + return { - granted: status.allPermissionsGranted, + granted: allGranted, details: { notifications: status.notificationsEnabled, exactAlarm: status.exactAlarmEnabled, @@ -134,6 +244,41 @@ export class NativeNotificationService implements NotificationServiceInterface { }, ); + // Note: The notification channel should be created automatically by the plugin + // when the notification is delivered. The "Channel does not exist" warning + // during permission checks is expected and not a problem - the channel will + // be created when the receiver tries to show the notification. + + // Check permissions before scheduling to ensure everything is set up correctly + logger.debug( + "[NativeNotificationService] Checking permissions before scheduling", + ); + const permissionStatus = await this.checkPermissions(); + logger.info( + "[NativeNotificationService] Permission status:", + JSON.stringify(permissionStatus, null, 2), + ); + + // Check if permissions are actually granted (all details must be true) + const allPermissionsGranted = + permissionStatus.details?.notifications && + permissionStatus.details?.exactAlarm && + permissionStatus.details?.backgroundRefresh; + + if (!allPermissionsGranted) { + logger.warn( + "[NativeNotificationService] Permissions not fully granted. Details:", + permissionStatus.details, + ); + // Continue anyway - the plugin might handle permission requests internally + // but log a warning for debugging + } else { + logger.debug( + "[NativeNotificationService] All permissions granted:", + permissionStatus.details, + ); + } + // Cancel any existing notification with the same ID before scheduling a new one // This ensures the old notification is removed from iOS notification center try { @@ -155,6 +300,48 @@ export class NativeNotificationService implements NotificationServiceInterface { ); } + // Log current time and scheduled time for debugging + const now = new Date(); + const [hours, minutes] = options.time.split(":").map(Number); + const scheduledTime = new Date(); + scheduledTime.setHours(hours, minutes, 0, 0); + + // If scheduled time is in the past, it should be scheduled for tomorrow + if (scheduledTime < now) { + scheduledTime.setDate(scheduledTime.getDate() + 1); + logger.info( + "[NativeNotificationService] Scheduled time is in the past, will schedule for tomorrow:", + { + requestedTime: options.time, + currentTime: now.toISOString(), + scheduledFor: scheduledTime.toISOString(), + }, + ); + } else { + logger.info("[NativeNotificationService] Scheduling notification:", { + requestedTime: options.time, + currentTime: now.toISOString(), + scheduledFor: scheduledTime.toISOString(), + minutesUntilNotification: Math.round( + (scheduledTime.getTime() - now.getTime()) / 1000 / 60, + ), + }); + } + + logger.debug( + "[NativeNotificationService] Calling scheduleDailyReminder with options:", + { + id: this.reminderId, + title: options.title, + body: options.body, + time: options.time, + repeatDaily: true, + sound: true, + vibration: true, + priority: options.priority || "normal", + }, + ); + await DailyNotification.scheduleDailyReminder({ id: this.reminderId, title: options.title, @@ -166,56 +353,91 @@ export class NativeNotificationService implements NotificationServiceInterface { priority: options.priority || "normal", }); - // Verify the notification was actually scheduled - logger.debug( - "[NativeNotificationService] Verifying notification was scheduled", + logger.info( + "[NativeNotificationService] scheduleDailyReminder call completed successfully", + { + reminderId: this.reminderId, + requestedTime: options.time, + }, ); - const remindersResult = await DailyNotification.getScheduledReminders(); - // Handle both array and object with reminders property - const reminders = Array.isArray(remindersResult) - ? remindersResult - : (remindersResult as { reminders: typeof remindersResult }) - .reminders || []; - const scheduledReminder = reminders.find((r) => r.id === this.reminderId); - if (scheduledReminder && scheduledReminder.isScheduled) { - // Verify the time matches what we scheduled - if (scheduledReminder.time !== options.time) { - logger.error( - "[NativeNotificationService] Notification time mismatch!", + // Verify the notification was actually scheduled (if method is available) + // Note: getScheduledReminders() is not implemented on Android, so we + // only verify on iOS. On Android, we assume success if no error was thrown. + try { + logger.debug( + "[NativeNotificationService] Attempting to verify notification was scheduled", + ); + const remindersResult = await DailyNotification.getScheduledReminders(); + // Handle both array and object with reminders property + const reminders = Array.isArray(remindersResult) + ? remindersResult + : (remindersResult as { reminders: typeof remindersResult }) + .reminders || []; + const scheduledReminder = reminders.find( + (r) => r.id === this.reminderId, + ); + + if (scheduledReminder && scheduledReminder.isScheduled) { + // Verify the time matches what we scheduled + if (scheduledReminder.time !== options.time) { + logger.error( + "[NativeNotificationService] Notification time mismatch!", + { + scheduled: scheduledReminder.time, + requested: options.time, + reminderId: this.reminderId, + }, + ); + return false; + } + + logger.info( + "[NativeNotificationService] Daily notification verified as scheduled:", { - scheduled: scheduledReminder.time, - requested: options.time, - reminderId: this.reminderId, + id: scheduledReminder.id, + time: scheduledReminder.time, + requestedTime: options.time, + nextTriggerTime: scheduledReminder.nextTriggerTime, }, ); + return true; + } else { + logger.warn( + "[NativeNotificationService] Notification was not found in scheduled reminders after scheduling", + { + reminderId: this.reminderId, + requestedTime: options.time, + allReminders: reminders.map((r) => ({ + id: r.id, + time: r.time, + isScheduled: r.isScheduled, + })), + }, + ); + // On iOS, if verification fails, return false + // On Android, this method isn't available, so we'll fall through to return true return false; } - - logger.info( - "[NativeNotificationService] Daily notification scheduled successfully:", - { - id: scheduledReminder.id, - time: scheduledReminder.time, - requestedTime: options.time, - nextTriggerTime: scheduledReminder.nextTriggerTime, - }, + } catch (verifyError) { + // If getScheduledReminders() is not implemented (Android), assume success + // since scheduleDailyReminder() completed without error + if ( + verifyError instanceof Error && + verifyError.message.includes("not implemented") + ) { + logger.debug( + "[NativeNotificationService] getScheduledReminders() not available on this platform (expected on Android). " + + "Assuming success since scheduleDailyReminder() completed without error.", + ); + return true; + } + // For other errors, log but still assume success since the schedule call succeeded + logger.warn( + "[NativeNotificationService] Verification failed, but schedule call succeeded:", + verifyError, ); return true; - } else { - logger.warn( - "[NativeNotificationService] Notification was not found in scheduled reminders after scheduling", - { - reminderId: this.reminderId, - requestedTime: options.time, - allReminders: reminders.map((r) => ({ - id: r.id, - time: r.time, - isScheduled: r.isScheduled, - })), - }, - ); - return false; } } catch (error) { logger.error("[NativeNotificationService] Schedule failed:", error);