feat(ios): add local notification diagnostics for debug panels

Expose bridge-safe pending, delivered, and settings snapshots from
UNUserNotificationCenter so apps can inspect local notification state
without native date objects or large payloads.

- Add getDeliveredNotifications and getNotificationSettings on iOS
- Harden getPendingNotifications with triggerTimestamp/triggerDateIso
- Always resolve with serializable values; omit userInfo from delivered
- Register new Capacitor methods and TypeScript definitions/web stubs
This commit is contained in:
Jose Olarte III
2026-05-21 18:20:29 +08:00
parent 4027bb0c37
commit 7f7d65cae6
3 changed files with 295 additions and 43 deletions

View File

@@ -2170,55 +2170,89 @@ public class DailyNotificationPlugin: CAPPlugin {
}
/**
* Get pending notifications (iOS-specific)
* Get pending notifications (iOS diagnostics)
*
* Exposes UNUserNotificationCenter pending requests with bridge-safe fields only.
*
* @param call Plugin call
*/
@objc func getPendingNotifications(_ call: CAPPluginCall) {
Task {
do {
// Delegate to UNUserNotificationCenter for pending requests
let requests = try await notificationCenter.pendingNotificationRequests()
var notifications: [[String: Any]] = []
for request in requests {
let content = request.content
var triggerDate: Int64 = 0
if let calendarTrigger = request.trigger as? UNCalendarNotificationTrigger {
if let nextDate = calendarTrigger.nextTriggerDate() {
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
}
} else if let timeIntervalTrigger = request.trigger as? UNTimeIntervalNotificationTrigger {
if let nextDate = timeIntervalTrigger.nextTriggerDate() {
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
}
}
let notification: [String: Any] = [
"identifier": request.identifier,
"title": content.title,
"body": content.body,
"triggerDate": triggerDate,
"triggerType": request.trigger is UNCalendarNotificationTrigger ? "calendar" : (request.trigger is UNTimeIntervalNotificationTrigger ? "timeInterval" : "location"),
"repeats": request.trigger?.repeats ?? false
]
notifications.append(notification)
}
let result: [String: Any] = [
"count": notifications.count,
"notifications": notifications
diagnosticLog("getPendingNotifications called")
notificationCenter.getPendingNotificationRequests { requests in
var notifications: [[String: Any]] = []
for request in requests {
let content = request.content
let (triggerTimestamp, triggerDateIso) = Self.pendingTriggerFields(from: request.trigger)
var item: [String: Any] = [
"identifier": request.identifier,
"title": content.title,
"body": content.body,
"triggerType": Self.pendingTriggerTypeString(from: request.trigger),
"repeats": request.trigger?.repeats ?? false
]
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
DispatchQueue.main.async {
call.reject("Failed to get pending notifications: \(error.localizedDescription)", "pending_notifications_failed")
if let triggerTimestamp = triggerTimestamp {
item["triggerTimestamp"] = triggerTimestamp
item["triggerDate"] = triggerTimestamp
} else {
item["triggerTimestamp"] = NSNull()
item["triggerDate"] = NSNull()
}
item["triggerDateIso"] = triggerDateIso ?? NSNull()
notifications.append(item)
}
self.diagnosticLog("getPendingNotifications resolved count=\(notifications.count)")
call.resolve([
"count": notifications.count,
"notifications": notifications
])
}
}
/**
* Get delivered notification metadata (iOS diagnostics)
*
* Lightweight delivery snapshot; omits userInfo and attachment payloads.
*
* @param call Plugin call
*/
@objc func getDeliveredNotifications(_ call: CAPPluginCall) {
diagnosticLog("getDeliveredNotifications called")
notificationCenter.getDeliveredNotifications { delivered in
var notifications: [[String: Any]] = []
for notification in delivered {
let request = notification.request
let content = request.content
let deliveredTimestamp = Int64(notification.date.timeIntervalSince1970 * 1000)
notifications.append([
"identifier": request.identifier,
"deliveredTimestamp": deliveredTimestamp,
"deliveredDateIso": Self.iso8601String(from: notification.date) ?? NSNull(),
"title": content.title,
"body": content.body,
"categoryIdentifier": content.categoryIdentifier.isEmpty ? NSNull() : content.categoryIdentifier
])
}
self.diagnosticLog("getDeliveredNotifications resolved count=\(notifications.count)")
call.resolve([
"count": notifications.count,
"notifications": notifications
])
}
}
/**
* Get notification authorization and presentation settings (iOS diagnostics)
*
* Always resolves with serializable settings; does not require plugin scheduler init.
*
* @param call Plugin call
*/
@objc func getNotificationSettings(_ call: CAPPluginCall) {
diagnosticLog("getNotificationSettings called")
notificationCenter.getNotificationSettings { settings in
let result = Self.serializeNotificationSettings(settings)
self.diagnosticLog("getNotificationSettings resolved authorization=\(result["authorizationStatus"] ?? "unknown")")
call.resolve(result)
}
}
@@ -2618,6 +2652,97 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
// MARK: - Diagnostics helpers
extension DailyNotificationPlugin {
private func diagnosticLog(_ message: String) {
NSLog("[DailyNotificationPlugin] %@", message)
}
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
static func iso8601String(from date: Date) -> String? {
iso8601Formatter.string(from: date)
}
static func pendingTriggerFields(from trigger: UNNotificationTrigger?) -> (Int64?, String?) {
guard let trigger = trigger else { return (nil, nil) }
var nextDate: Date?
if let calendarTrigger = trigger as? UNCalendarNotificationTrigger {
nextDate = calendarTrigger.nextTriggerDate()
} else if let timeIntervalTrigger = trigger as? UNTimeIntervalNotificationTrigger {
nextDate = timeIntervalTrigger.nextTriggerDate()
}
guard let date = nextDate else { return (nil, nil) }
let timestamp = Int64(date.timeIntervalSince1970 * 1000)
return (timestamp, iso8601String(from: date))
}
static func pendingTriggerTypeString(from trigger: UNNotificationTrigger?) -> String {
guard let trigger = trigger else { return "unknown" }
if trigger is UNCalendarNotificationTrigger { return "calendar" }
if trigger is UNTimeIntervalNotificationTrigger { return "timeInterval" }
if trigger is UNLocationNotificationTrigger { return "location" }
return "unknown"
}
static func authorizationStatusString(_ status: UNAuthorizationStatus) -> String {
if #available(iOS 14.0, *), status == .ephemeral {
return "ephemeral"
}
switch status {
case .notDetermined: return "notDetermined"
case .denied: return "denied"
case .authorized: return "authorized"
case .provisional: return "provisional"
@unknown default: return "unknown"
}
}
static func notificationSettingString(_ setting: UNNotificationSetting) -> String {
switch setting {
case .notSupported: return "notSupported"
case .disabled: return "disabled"
case .enabled: return "enabled"
@unknown default: return "unknown"
}
}
static func showPreviewsSettingString(_ setting: UNShowPreviewsSetting) -> String {
switch setting {
case .always: return "always"
case .whenAuthenticated: return "whenAuthenticated"
case .never: return "never"
@unknown default: return "unknown"
}
}
static func serializeNotificationSettings(_ settings: UNNotificationSettings) -> [String: Any] {
var result: [String: Any] = [
"authorizationStatus": authorizationStatusString(settings.authorizationStatus),
"notificationCenterSetting": notificationSettingString(settings.notificationCenterSetting),
"lockScreenSetting": notificationSettingString(settings.lockScreenSetting),
"carPlaySetting": notificationSettingString(settings.carPlaySetting),
"alertSetting": notificationSettingString(settings.alertSetting),
"badgeSetting": notificationSettingString(settings.badgeSetting),
"soundSetting": notificationSettingString(settings.soundSetting),
"criticalAlertSetting": notificationSettingString(settings.criticalAlertSetting),
"showPreviewsSetting": showPreviewsSettingString(settings.showPreviewsSetting),
]
if #available(iOS 13.0, *) {
result["announcementSetting"] = notificationSettingString(settings.announcementSetting)
}
if #available(iOS 15.0, *) {
result["scheduledDeliverySetting"] = notificationSettingString(settings.scheduledDeliverySetting)
result["timeSensitiveSetting"] = notificationSettingString(settings.timeSensitiveSetting)
}
return result
}
}
// MARK: - CAPBridgedPlugin Conformance
// This extension makes the plugin conform to CAPBridgedPlugin protocol
// which is required for Capacitor to discover and register the plugin
@@ -2651,6 +2776,8 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "getNotificationPermissionStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "requestNotificationPermission", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getPendingNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getDeliveredNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationSettings", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getBackgroundTaskStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openNotificationSettings", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openBackgroundAppRefreshSettings", returnType: CAPPluginReturnPromise))

View File

@@ -155,6 +155,84 @@ export interface PermissionStatusResult {
allPermissionsGranted: boolean;
}
/** iOS UNUserNotificationCenter authorization flags (diagnostics) */
export interface NotificationPermissionStatus {
authorized: boolean;
denied: boolean;
notDetermined: boolean;
provisional: boolean;
}
/** Pending UNNotificationRequest snapshot for debug panels (iOS) */
export interface PendingNotificationDiagnostic {
identifier: string;
triggerTimestamp: number | null;
triggerDateIso: string | null;
title?: string;
body?: string;
/** @deprecated Prefer triggerTimestamp */
triggerDate?: number | null;
triggerType?: 'calendar' | 'timeInterval' | 'location' | 'unknown';
repeats?: boolean;
}
export interface PendingNotificationsResult {
count: number;
notifications: PendingNotificationDiagnostic[];
}
/** Delivered UNNotification metadata for debug panels (iOS) */
export interface DeliveredNotificationMetadata {
identifier: string;
deliveredTimestamp: number;
deliveredDateIso: string | null;
title: string;
body: string;
categoryIdentifier: string | null;
}
export interface DeliveredNotificationsResult {
count: number;
notifications: DeliveredNotificationMetadata[];
}
/**
* UNNotificationSettings snapshot for debug panels (iOS).
* Not the app-level {@link NotificationSettings} schedule preferences type.
*/
export interface NotificationCenterSettingsSnapshot {
authorizationStatus: string;
notificationCenterSetting: string;
lockScreenSetting: string;
carPlaySetting: string;
alertSetting: string;
badgeSetting: string;
soundSetting: string;
criticalAlertSetting: string;
showPreviewsSetting: string;
announcementSetting?: string;
scheduledDeliverySetting?: string;
timeSensitiveSetting?: string;
}
/** @deprecated Use PendingNotificationDiagnostic */
export interface PendingNotification {
identifier: string;
title: string;
body: string;
triggerDate: number;
triggerType: 'calendar' | 'timeInterval' | 'location';
repeats: boolean;
}
export interface BackgroundTaskStatus {
fetchTaskRegistered: boolean;
notifyTaskRegistered: boolean;
lastFetchExecution: number | null;
lastNotifyExecution: number | null;
backgroundRefreshEnabled: boolean | null;
}
// Static Daily Reminder Interfaces
export interface DailyReminderOptions {
id: string;
@@ -538,6 +616,16 @@ export interface DailyNotificationPlugin {
openChannelSettings(channelId?: string): Promise<void>;
checkStatus(): Promise<NotificationStatus>;
// iOS local notification diagnostics (bridge-safe, no backend concepts)
getPendingNotifications(): Promise<PendingNotificationsResult>;
getDeliveredNotifications(): Promise<DeliveredNotificationsResult>;
getNotificationSettings(): Promise<NotificationCenterSettingsSnapshot>;
getNotificationPermissionStatus(): Promise<NotificationPermissionStatus>;
requestNotificationPermission(): Promise<{ granted: boolean }>;
getBackgroundTaskStatus(): Promise<BackgroundTaskStatus>;
openNotificationSettings(): Promise<void>;
openBackgroundAppRefreshSettings(): Promise<void>;
// New dual scheduling methods
scheduleContentFetch(config: ContentFetchConfig): Promise<void>;
scheduleUserNotification(config: UserNotificationConfig): Promise<void>;

View File

@@ -22,6 +22,11 @@ import type {
CreateCallbackInput,
History,
HistoryStats,
PendingNotificationsResult,
DeliveredNotificationsResult,
NotificationCenterSettingsSnapshot,
NotificationPermissionStatus,
BackgroundTaskStatus,
} from './definitions';
import { DailyNotificationError, ErrorCode } from './core/errors';
@@ -204,6 +209,38 @@ export class DailyNotificationWeb implements DailyNotificationPlugin {
this.throwNotSupported();
}
async getPendingNotifications(): Promise<PendingNotificationsResult> {
this.throwNotSupported();
}
async getDeliveredNotifications(): Promise<DeliveredNotificationsResult> {
this.throwNotSupported();
}
async getNotificationSettings(): Promise<NotificationCenterSettingsSnapshot> {
this.throwNotSupported();
}
async getNotificationPermissionStatus(): Promise<NotificationPermissionStatus> {
this.throwNotSupported();
}
async requestNotificationPermission(): Promise<{ granted: boolean }> {
this.throwNotSupported();
}
async getBackgroundTaskStatus(): Promise<BackgroundTaskStatus> {
this.throwNotSupported();
}
async openNotificationSettings(): Promise<void> {
this.throwNotSupported();
}
async openBackgroundAppRefreshSettings(): Promise<void> {
this.throwNotSupported();
}
async isChannelEnabled(): Promise<{ enabled: boolean; channelId: string }> {
this.throwNotSupported();
}