diff --git a/doc/directives/0003-iOS-Android-Parity-Directive.md b/doc/directives/0003-iOS-Android-Parity-Directive.md index c4ea075..097f527 100644 --- a/doc/directives/0003-iOS-Android-Parity-Directive.md +++ b/doc/directives/0003-iOS-Android-Parity-Directive.md @@ -540,6 +540,10 @@ A "successful run" is defined as: BGTask handler invoked, content fetch complete | `cancelAllNotifications()` | `cancelAllNotifications(): Promise` | `@objc func cancelAllNotifications(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | | `getNotificationStatus()` | `getNotificationStatus(): Promise` | `@objc func getNotificationStatus(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | | `updateSettings()` | `updateSettings(settings: NotificationSettings): Promise` | `@objc func updateSettings(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | +| `checkPermissionStatus()` | `checkPermissionStatus(): Promise` | `@objc func checkPermissionStatus(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | +| `requestNotificationPermissions()` | `requestNotificationPermissions(): Promise` | `@objc func requestNotificationPermissions(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | +| `isChannelEnabled()` | `isChannelEnabled(channelId?: string): Promise<{ enabled: boolean; channelId: string }>` | `@objc func isChannelEnabled(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | +| `openChannelSettings()` | `openChannelSettings(channelId?: string): Promise` | `@objc func openChannelSettings(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 1 | ✅ Complete | | `getBatteryStatus()` | `getBatteryStatus(): Promise` | `@objc func getBatteryStatus(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 2 | ❌ Missing | | `requestBatteryOptimizationExemption()` | `requestBatteryOptimizationExemption(): Promise` | `@objc func requestBatteryOptimizationExemption(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 2 | ❌ Missing | | `setAdaptiveScheduling()` | `setAdaptiveScheduling(options: { enabled: boolean }): Promise` | `@objc func setAdaptiveScheduling(_ call: CAPPluginCall)` | `DailyNotificationPlugin.swift` | 2 | ❌ Missing | @@ -612,6 +616,10 @@ A "successful run" is defined as: BGTask handler invoked, content fetch complete - [x] `cancelAllNotifications()` - Cancel all notifications ✅ - [x] `getNotificationStatus()` - Status retrieval ✅ - [x] `updateSettings(settings: NotificationSettings)` - Settings update ✅ +- [x] `checkPermissionStatus()` - Permission status check ✅ +- [x] `requestNotificationPermissions()` - Request permissions ✅ +- [x] `isChannelEnabled(channelId?)` - Channel enabled check (iOS: app-wide) ✅ +- [x] `openChannelSettings(channelId?)` - Open channel settings (iOS: app Settings) ✅ ### Power Management Methods (Phase 2) @@ -703,6 +711,11 @@ A "successful run" is defined as: BGTask handler invoked, content fetch complete - Background refresh tasks: ~30 seconds execution time - Processing tasks: Variable, depends on system resources - **Strategy:** Efficient processing, immediate next-schedule after completion +- **Simulator Limitation:** BGTaskScheduler doesn't work reliably on simulator (Code=1: notPermitted) + - This is **expected behavior** - background fetch scheduling will fail on simulator + - Notifications will still be delivered, just without prefetch + - Real device testing required to verify background fetch works + - Error handling logs clear message that simulator limitation is expected ### iOS Storage Options @@ -1387,6 +1400,42 @@ scripts/ **Status:** ✅ **METHODS IMPLEMENTED** (2025-11-13) - `checkPermissionStatus()` - Returns current notification permission status - `requestNotificationPermissions()` - Requests notification permissions (shows system dialog if `.notDetermined`) +- `isChannelEnabled(channelId?)` - Checks if notifications are enabled (iOS: app-wide check, not per-channel) +- `openChannelSettings(channelId?)` - Opens notification settings (iOS: opens app Settings, not per-channel) +- `scheduleDailyNotification(options)` - Schedules daily notification (✅ working, BGTaskScheduler prefetch fails on simulator - expected) + +### 2025-11-13: scheduleDailyNotification Implementation + +**Decision:** Implement `scheduleDailyNotification` with proper parameter handling +**Rationale:** Test app needs to schedule notifications for testing +**Status:** ✅ Complete + +**Implementation Details:** + +1. **Parameter Reading Fix:** + - **Issue:** iOS code was looking for `call.getObject("options")` but Capacitor passes parameters directly + - **Fix:** Changed to read parameters directly from `call` (matching Android pattern) + - **Files Affected:** `ios/Plugin/DailyNotificationPlugin.swift` + - **Lesson:** Capacitor passes the options object directly as call data, not wrapped in "options" key + +2. **BGTaskScheduler Simulator Limitation:** + - **Issue:** Background fetch scheduling fails on simulator with `BGTaskSchedulerErrorDomain Code=1` (notPermitted) + - **Behavior:** This is **expected** on simulator - BGTaskScheduler doesn't work reliably in simulators + - **Solution:** Improved error handling to log clear message that this is expected on simulator + - **Impact:** Notification scheduling still works; prefetch won't run on simulator but will work on real devices + - **Files Affected:** `ios/Plugin/DailyNotificationPlugin.swift` + - **Lesson:** BGTaskScheduler requires real device or Background App Refresh enabled; simulator limitations are normal + +3. **Notification Scheduling:** + - ✅ Notification scheduling works correctly + - ✅ Storage saves notification content + - ✅ Background fetch scheduling attempted (fails on simulator, works on device) + - ✅ Error handling doesn't fail notification scheduling if background fetch fails + +**Testing Notes:** +- Notification scheduling verified working on simulator +- Background fetch error is expected and doesn't prevent notification delivery +- Real device testing required to verify background fetch works --- diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index 291a5d1..af4a0ee 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -863,15 +863,9 @@ public class DailyNotificationPlugin: CAPPlugin { return } - guard let options = call.getObject("options") else { - let error = DailyNotificationErrorCodes.missingParameter("options") - let errorMessage = error["message"] as? String ?? "Unknown error" - let errorCode = error["error"] as? String ?? "unknown_error" - call.reject(errorMessage, errorCode) - return - } - - guard let time = options["time"] as? String, !time.isEmpty else { + // Capacitor passes the options object directly as call data + // Read parameters directly from call (matching Android implementation) + guard let time = call.getString("time"), !time.isEmpty else { let error = DailyNotificationErrorCodes.missingParameter("time") let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" @@ -893,11 +887,12 @@ public class DailyNotificationPlugin: CAPPlugin { return } - // Extract other parameters - let title = options["title"] as? String ?? "Daily Update" - let body = options["body"] as? String ?? "Your daily notification is ready" - let sound = options["sound"] as? Bool ?? true - let url = options["url"] as? String + // Extract other parameters (with defaults matching Android) + let title = call.getString("title") ?? "Daily Update" + let body = call.getString("body") ?? "Your daily notification is ready" + let sound = call.getBool("sound", true) + let url = call.getString("url") + let priority = call.getString("priority") ?? "default" // Calculate scheduled time (next occurrence at specified hour:minute) let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute) @@ -1267,6 +1262,92 @@ public class DailyNotificationPlugin: CAPPlugin { } } + // MARK: - Channel Methods (iOS Parity with Android) + + /** + * Check if notification channel is enabled + * + * iOS Note: iOS doesn't have per-channel control like Android. This method + * checks if notifications are authorized app-wide, which is the iOS equivalent. + * + * @param call Plugin call with optional channelId parameter + */ + @objc func isChannelEnabled(_ call: CAPPluginCall) { + NSLog("DNP-PLUGIN: isChannelEnabled called") + + // Ensure scheduler is initialized + if scheduler == nil { + scheduler = DailyNotificationScheduler() + } + + guard let scheduler = scheduler else { + let error = DailyNotificationErrorCodes.createErrorResponse( + code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, + message: "Plugin not initialized" + ) + let errorMessage = error["message"] as? String ?? "Unknown error" + let errorCode = error["error"] as? String ?? "unknown_error" + call.reject(errorMessage, errorCode) + return + } + + // Get channelId from call (optional, for API parity with Android) + let channelId = call.getString("channelId") ?? "default" + + // iOS doesn't have per-channel control, so check app-wide notification authorization + Task { + let status = await scheduler.checkPermissionStatus() + let enabled = (status == .authorized || status == .provisional) + + let result: [String: Any] = [ + "enabled": enabled, + "channelId": channelId + ] + + DispatchQueue.main.async { + call.resolve(result) + } + } + } + + /** + * Open notification channel settings + * + * iOS Note: iOS doesn't have per-channel settings. This method opens + * the app's notification settings in iOS Settings, which is the iOS equivalent. + * + * @param call Plugin call with optional channelId parameter + */ + @objc func openChannelSettings(_ call: CAPPluginCall) { + NSLog("DNP-PLUGIN: openChannelSettings called") + + // Get channelId from call (optional, for API parity with Android) + let channelId = call.getString("channelId") ?? "default" + + // iOS doesn't have per-channel settings, so open app-wide notification settings + if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { + if UIApplication.shared.canOpenURL(settingsUrl) { + UIApplication.shared.open(settingsUrl) { success in + if success { + NSLog("DNP-PLUGIN: Opened iOS Settings for channel: %@", channelId) + DispatchQueue.main.async { + call.resolve() + } + } else { + NSLog("DNP-PLUGIN: Failed to open iOS Settings") + DispatchQueue.main.async { + call.reject("Failed to open settings") + } + } + } + } else { + call.reject("Cannot open settings URL") + } + } else { + call.reject("Invalid settings URL") + } + } + /** * Update notification settings * @@ -1371,7 +1452,20 @@ public class DailyNotificationPlugin: CAPPlugin { print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(fetchDate)") } catch { - print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)") + // BGTaskScheduler errors are common on simulator (Code=1: notPermitted) + // This is expected behavior - simulators don't reliably support background tasks + // On real devices, this should work if Background App Refresh is enabled + let errorDescription = error.localizedDescription + if errorDescription.contains("BGTaskSchedulerErrorDomain") || + errorDescription.contains("Code=1") || + (error as NSError).domain == "BGTaskSchedulerErrorDomain" { + print("DNP-FETCH-SCHEDULE: Background fetch scheduling failed (expected on simulator): \(errorDescription)") + print("DNP-FETCH-SCHEDULE: Note: BGTaskScheduler requires real device or Background App Refresh enabled") + } else { + print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)") + } + // Don't fail notification scheduling if background fetch fails + // Notification will still be delivered, just without prefetch } } } @@ -1403,6 +1497,10 @@ public class DailyNotificationPlugin: CAPPlugin { methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise)) + // Channel methods (iOS parity with Android) + methods.append(CAPPluginMethod(name: "isChannelEnabled", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "openChannelSettings", returnType: CAPPluginReturnPromise)) + // Reminder methods methods.append(CAPPluginMethod(name: "scheduleDailyReminder", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "cancelDailyReminder", returnType: CAPPluginReturnPromise))