From 3fa72074c0135d0b6cbcc6621c1815ba15ea74a0 Mon Sep 17 00:00:00 2001 From: Mohanned Binmiskeen Date: Wed, 15 Jul 2026 10:42:36 +0300 Subject: [PATCH] release: 0.3.0 Notification permission handling: - Add syncNotificationPermission() to force an on-demand OS-permission re-check and sync the change to PushFire. - Add openNotificationSettings() to deep-link the user into the OS settings page for the permanently-denied case. - Rewrite README permission docs; correct the re-request guidance. iOS registration: - Wait for the APNS token before requesting the FCM token so iOS auto-registration no longer hard-fails with apns-token-not-set. - Add PushFireConfig.getFcmTokenOverride and iosRegisterWithoutPrompt. Bump version to 0.3.0; ignore example ios derived-data logs. --- .gitignore | 1 + CHANGELOG.md | 14 ++ README.md | 52 ++++- example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Podfile | 2 +- example/ios/Podfile.lock | 192 ++++++++++++++++++ example/ios/Runner.xcodeproj/project.pbxproj | 139 ++++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 3 + .../contents.xcworkspacedata | 3 + example/ios/Runner/AppDelegate.swift | 7 +- example/ios/Runner/Info.plist | 33 ++- example/ios/Runner/Runner.entitlements | 8 + example/lib/main.dart | 6 +- example/pubspec.lock | 18 +- lib/pushfire_sdk.dart | 24 +++ lib/src/config/pushfire_config.dart | 32 +++ lib/src/pushfire_sdk_impl.dart | 28 +++ lib/src/services/device_service.dart | 107 +++++++++- pubspec.lock | 18 +- pubspec.yaml | 2 +- test/config/pushfire_config_test.dart | 33 +++ .../device_service_fcm_token_test.dart | 124 +++++++++++ ..._service_notification_preference_test.dart | 30 +++ 23 files changed, 838 insertions(+), 40 deletions(-) create mode 100644 example/ios/Podfile.lock create mode 100644 example/ios/Runner/Runner.entitlements create mode 100644 test/services/device_service_fcm_token_test.dart diff --git a/.gitignore b/.gitignore index 8cf277d..ac1c35d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ app.*.map.json **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ +**/ios/.derived-data-log-* **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ diff --git a/CHANGELOG.md b/CHANGELOG.md index eab9559..f7b8801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.0] + +### Added +- **`syncNotificationPermission()`** — forces an immediate re-check of the OS notification permission and syncs any change to PushFire (re-registering the device when it changed), returning the current `NotificationStatus`. Previously this only happened automatically when the app returned to the foreground, with no way to trigger it on demand. +- **`openNotificationSettings()`** — deep-links the user into the OS settings page for the app (wraps `permission_handler`'s `openAppSettings()`). Use it for the permanently-denied case, where `requestNotificationPermission()` no longer shows a system prompt. +- **`PushFireConfig.getFcmTokenOverride`** — optional `Future Function()` hook to supply your own (e.g. APNS-aware) FCM token fetcher. Previously only reachable via an internal test-only constructor. A constructor-level override still takes precedence when present. +- **`PushFireConfig.iosRegisterWithoutPrompt`** (iOS only, default `false`) — when `requestNotificationPermission` is `false`, opt in to trigger remote-notification registration without showing the authorization dialog, so an APNS/FCM token can still be obtained. Implemented via provisional authorization. Note: provisional authorization is not "no authorization" — it delivers notifications quietly to Notification Center and the user may be asked later to keep or disable them. For a truly authorization-free registration, call `application.registerForRemoteNotifications()` from your AppDelegate and leave this `false`. + +### Fixed +- **iOS auto-registration no longer hard-fails with `apns-token-not-set`.** On iOS the APNS token is delivered asynchronously by Apple after `registerForRemoteNotifications`, so calling `FirebaseMessaging.getToken()` during `initialize()` could throw `[firebase_messaging/apns-token-not-set]` and leave the device unregistered. `DeviceService` now waits for the APNS token (polls `getAPNSToken()` up to 10 times at 500ms) before requesting the FCM token. If the token never arrives (simulator, offline, or registration was never triggered), it skips `getToken()` and returns null instead of throwing — the device registers later via the existing `onTokenRefresh` listener. + +### Docs +- Rewrote the README notification-permissions section: documented the denied → settings flow, the automatic and on-demand permission sync, and corrected the "re-request strategy" guidance (re-requesting no longer prompts once permanently denied). + ## [0.2.1] ### Fixed diff --git a/README.md b/README.md index 3b28dcc..4a2f150 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,7 @@ await PushFireSDK.initialize( ); // Later, when appropriate for your UX -bool permissionGranted = await PushFireSDK.requestNotificationPermission(); +bool permissionGranted = await PushFireSDK.instance.requestNotificationPermission(); if (permissionGranted) { print('Notification permission granted'); } else { @@ -553,11 +553,55 @@ The SDK handles platform-specific permission requirements: - **Android**: Handles runtime permissions for Android 13+ (API level 33+) and gracefully handles older versions - **Web**: Requests browser notification permissions through Firebase Messaging +### Handling Permanently Denied Permission + +Once the user denies the notification prompt (iOS: any denial; Android: the second +denial), calling `requestNotificationPermission()` again no longer shows a system +dialog — it returns `false` without prompting. At that point the only way for the +user to enable notifications is through the OS settings app. Use +`openNotificationSettings()` to send them there: + +```dart +final granted = await PushFireSDK.instance.requestNotificationPermission(); +if (!granted) { + // The prompt was suppressed (permanently denied). Explain why notifications + // matter, then deep-link the user into the settings app. + final open = await showEnableNotificationsDialog(); // your own UI + if (open) { + await PushFireSDK.instance.openNotificationSettings(); + } +} +``` + +### Syncing Permission Changes Made in Settings + +When the user grants or revokes the notification permission from the OS settings +app, PushFire needs to know so it stops or resumes delivery. The SDK handles this +automatically: it observes the app lifecycle and, whenever the app returns to the +foreground, re-checks the OS permission and updates the device on the server if it +changed. No action is required for the common case. + +If you need to force an immediate sync — for example right after the user returns +from `openNotificationSettings()` — call `syncNotificationPermission()`: + +```dart +await PushFireSDK.instance.openNotificationSettings(); + +// ...after the user comes back to your app +final status = await PushFireSDK.instance.syncNotificationPermission(); +if (status?.isPermissionGranted ?? false) { + print('Notifications enabled and synced to PushFire'); +} +``` + +`syncNotificationPermission()` re-checks the OS permission, re-registers the device +with PushFire if it changed, and returns the current `NotificationStatus`. + ### Best Practices for Permissions 1. **Context Matters**: Request permissions when users understand the value of notifications 2. **Graceful Degradation**: Your app should work even if permissions are denied -3. **Re-request Strategy**: Use `requestNotificationPermission()` to re-request if initially denied +3. **Re-request Strategy**: `requestNotificationPermission()` re-prompts only while the permission is still undetermined. Once it is permanently denied the call returns `false` without a dialog — send the user to `openNotificationSettings()` instead 4. **User Education**: Explain the benefits before requesting permissions ### Permission Status Handling @@ -565,8 +609,8 @@ The SDK handles platform-specific permission requirements: The SDK automatically: - Logs permission request outcomes for debugging - Continues device registration even if permissions are denied -- Supports manual permission grants through device settings -- Re-registers the device when permissions are granted via manual request +- Detects permission changes made in the OS settings when the app returns to the foreground, and re-registers the device to sync the change to PushFire +- Exposes `syncNotificationPermission()` to force that sync on demand, and `openNotificationSettings()` to deep-link the user into the settings app ## Error Types diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index d97f17e..e51a31d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 0000000..fc4aa08 --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,192 @@ +PODS: + - app_links (6.4.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - Firebase/Auth (11.15.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 11.15.0) + - firebase_auth (5.7.0): + - Firebase/Auth (= 11.15.0) + - firebase_core + - Flutter + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) + - Flutter + - firebase_messaging (15.2.10): + - Firebase/Messaging (= 11.15.0) + - firebase_core + - Flutter + - FirebaseAppCheckInterop (11.15.0) + - FirebaseAuth (11.15.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.15.0) + - FirebaseCoreExtension (~> 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 101.0) + - FirebaseAuthInterop (11.15.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - Flutter (1.0.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.1): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.1): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.1)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.1) + - GoogleUtilities/Reachability (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (4.5.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - PromisesObjC (2.4.1) + - RecaptchaInterop (101.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - Flutter (from `Flutter`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - GTMSessionFetcher + - nanopb + - PromisesObjC + - RecaptchaInterop + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" + Flutter: + :path: Flutter + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_auth: 50af8366c87bb88c80ebeae62eb60189c7246b9b + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 + FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df + FirebaseAuth: a6575e5fbf46b046c58dc211a28a5fbdd8d4c83b + FirebaseAuthInterop: 7087d7a4ee4bc4de019b2d0c240974ed5d89e2fd + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreExtension: edbd30474b5ccf04e5f001470bdf6ea616af2435 + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + GTMSessionFetcher: fc75fc972958dceedee61cb662ae1da7a83a91cf + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 + +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 2eba41d..f4b4d3f 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 73886449B72131A5BF7D2DDE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1296766D0AD1577D259CEE4A /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D06EDCD4F8798EA6097AF197 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 319320C96E21B6936821C1D6 /* Pods_RunnerTests.framework */; }; D1F0C6BDFE1573387A6538E8 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7F492DB3CD833C9EDF7ACB5F /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ @@ -41,11 +43,17 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0425787DAB7C1F1D607A6E41 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 1296766D0AD1577D259CEE4A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 319320C96E21B6936821C1D6 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4BF6091E9AF60BE46A3FD0F2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 5E707D78D25007C5229850D6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 630E4574271AA2C041362433 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -57,13 +65,24 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA2D17F5E6C9EAD56C118E06 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + DA7B6E5025CAF7BEF721A499 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 4E57362E2BFC7629A3BF9BA1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D06EDCD4F8798EA6097AF197 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 73886449B72131A5BF7D2DDE /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -97,6 +116,8 @@ 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 7F492DB3CD833C9EDF7ACB5F /* GoogleService-Info.plist */, + ACB43D47ABCD3A3517F1ABB7 /* Pods */, + F92CA2E9D5216FC537D5595D /* Frameworks */, ); sourceTree = ""; }; @@ -124,6 +145,29 @@ path = Runner; sourceTree = ""; }; + ACB43D47ABCD3A3517F1ABB7 /* Pods */ = { + isa = PBXGroup; + children = ( + DA7B6E5025CAF7BEF721A499 /* Pods-Runner.debug.xcconfig */, + 0425787DAB7C1F1D607A6E41 /* Pods-Runner.release.xcconfig */, + AA2D17F5E6C9EAD56C118E06 /* Pods-Runner.profile.xcconfig */, + 5E707D78D25007C5229850D6 /* Pods-RunnerTests.debug.xcconfig */, + 4BF6091E9AF60BE46A3FD0F2 /* Pods-RunnerTests.release.xcconfig */, + 630E4574271AA2C041362433 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + F92CA2E9D5216FC537D5595D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1296766D0AD1577D259CEE4A /* Pods_Runner.framework */, + 319320C96E21B6936821C1D6 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -131,8 +175,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + AC50B69F9271D6003A846030 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 4E57362E2BFC7629A3BF9BA1 /* Frameworks */, ); buildRules = ( ); @@ -148,12 +194,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + CE1F68CBB6AAEF064767FD09 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 63E26DEAE37681D9B2F31D17 /* [CP] Embed Pods Frameworks */, + 442CA0F7CAD01C8417B3C3E7 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -242,6 +291,40 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 442CA0F7CAD01C8417B3C3E7 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 63E26DEAE37681D9B2F31D17 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -257,6 +340,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + AC50B69F9271D6003A846030 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CE1F68CBB6AAEF064767FD09 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -350,7 +477,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -373,6 +500,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.example.pushfireSdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -383,6 +511,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5E707D78D25007C5229850D6 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -400,6 +529,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 4BF6091E9AF60BE46A3FD0F2 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -415,6 +545,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 630E4574271AA2C041362433 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -477,7 +608,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -528,7 +659,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -553,6 +684,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.example.pushfireSdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -576,6 +708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.example.pushfireSdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..e3773d4 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 2d0881e..16d37b1 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,33 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +70,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/ios/Runner/Runner.entitlements b/example/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/example/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/example/lib/main.dart b/example/lib/main.dart index 5d2fc2d..ae05c50 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -19,8 +19,8 @@ void main() async { '370d68b4-9f91-46d3-af64-15247fd783eb', // Replace with your actual API key enableLogging: true, // Enable for debugging timeoutSeconds: 30, - // requestNotificationPermission: true, // Default: automatically request permissions - // requestNotificationPermission: false, // Disable automatic requests for manual control + // TEMP (device happy-path test): standard prompt to fetch a real token + requestNotificationPermission: true, ), ); print('PushFire SDK initialized successfully'); @@ -249,7 +249,7 @@ class _PushFireExampleState extends State { ); setState(() { - _status = 'Tag added: ${tag.tagId} = ${tag.value}'; + _status = 'Tag added: ${tag?.tagId} = ${tag?.value}'; }); _tagIdController.clear(); diff --git a/example/pubspec.lock b/example/pubspec.lock index 79f0cca..f0e2a2b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -316,18 +316,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -494,7 +494,7 @@ packages: path: ".." relative: true source: path - version: "0.1.9" + version: "0.2.1" realtime_client: dependency: transitive description: @@ -648,10 +648,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/lib/pushfire_sdk.dart b/lib/pushfire_sdk.dart index 9a3ed26..29974ab 100644 --- a/lib/pushfire_sdk.dart +++ b/lib/pushfire_sdk.dart @@ -330,6 +330,30 @@ class PushFireSDK { return await PushFireSDKImpl.instance.getNotificationStatus(); } + /// Re-check the OS notification permission and sync any change to PushFire. + /// + /// The SDK syncs automatically when the app returns to the foreground, but + /// call this to force an immediate sync — typically right after the user + /// returns from the settings page opened via [openNotificationSettings]. + /// + /// Returns the current [NotificationStatus] after syncing, or null on web. + Future syncNotificationPermission() async { + if (kIsWeb) return null; + return await PushFireSDKImpl.instance.syncNotificationPermission(); + } + + /// Open the OS settings page for this app so the user can grant the + /// notification permission manually. + /// + /// Useful when the permission has been permanently denied and + /// [requestNotificationPermission] no longer shows a system prompt. + /// + /// Returns true if the settings page was opened. Returns false on web. + Future openNotificationSettings() async { + if (kIsWeb) return false; + return await PushFireSDKImpl.instance.openNotificationSettings(); + } + // Event streams /// Stream of device registration events diff --git a/lib/src/config/pushfire_config.dart b/lib/src/config/pushfire_config.dart index 762e31b..310be9c 100644 --- a/lib/src/config/pushfire_config.dart +++ b/lib/src/config/pushfire_config.dart @@ -18,6 +18,31 @@ class PushFireConfig { /// Automatically request notification permission during SDK initialization final bool requestNotificationPermission; + /// iOS only. When [requestNotificationPermission] is false, still trigger + /// remote-notification registration so an APNS token (and therefore an FCM + /// token) can be obtained without showing the interruptive permission + /// dialog. + /// + /// This works by requesting *provisional* authorization: the OS calls + /// `registerForRemoteNotifications` and delivers notifications quietly to + /// Notification Center without a prompt. Note that provisional authorization + /// is not the same as "no authorization" — the user can later be asked to + /// keep or turn off notifications. For a truly authorization-free + /// registration, call `application.registerForRemoteNotifications()` from + /// your AppDelegate instead and leave this false. + /// + /// No effect on Android or when [requestNotificationPermission] is true. + final bool iosRegisterWithoutPrompt; + + /// Optional override for how the FCM token is obtained. + /// + /// When supplied, the SDK calls this instead of its built-in + /// FirebaseMessaging logic. Useful as an escape hatch on iOS to plug in an + /// APNS-aware token fetcher, or in tests. Return null to indicate no token + /// is available yet (device registration is skipped and retried via + /// onTokenRefresh). + final Future Function()? getFcmTokenOverride; + const PushFireConfig({ required this.apiKey, this.baseUrl = 'https://api.pushfire.app/functions/v1/', @@ -25,6 +50,8 @@ class PushFireConfig { this.timeoutSeconds = 30, this.authProvider = AuthProvider.none, this.requestNotificationPermission = true, + this.iosRegisterWithoutPrompt = false, + this.getFcmTokenOverride, }); /// Create a copy of this config with updated values @@ -35,6 +62,8 @@ class PushFireConfig { int? timeoutSeconds, AuthProvider? authProvider, bool? requestNotificationPermission, + bool? iosRegisterWithoutPrompt, + Future Function()? getFcmTokenOverride, }) { return PushFireConfig( apiKey: apiKey ?? this.apiKey, @@ -44,6 +73,9 @@ class PushFireConfig { authProvider: authProvider ?? this.authProvider, requestNotificationPermission: requestNotificationPermission ?? this.requestNotificationPermission, + iosRegisterWithoutPrompt: + iosRegisterWithoutPrompt ?? this.iosRegisterWithoutPrompt, + getFcmTokenOverride: getFcmTokenOverride ?? this.getFcmTokenOverride, ); } diff --git a/lib/src/pushfire_sdk_impl.dart b/lib/src/pushfire_sdk_impl.dart index 930c71a..21c2986 100644 --- a/lib/src/pushfire_sdk_impl.dart +++ b/lib/src/pushfire_sdk_impl.dart @@ -537,6 +537,34 @@ class PushFireSDKImpl with WidgetsBindingObserver { return await _deviceService.getNotificationStatus(); } + /// Re-check the OS notification permission and sync the change to PushFire. + /// + /// The SDK already does this automatically when the app returns to the + /// foreground, but call this to force an immediate sync — for example right + /// after sending the user to the settings app via [openNotificationSettings]. + /// If the OS permission changed since the last check the device is + /// re-registered with the updated status. + /// + /// Returns the current [NotificationStatus] after syncing. + Future syncNotificationPermission() async { + _ensureInitialized(); + final device = await _deviceService.checkAndHandlePermissionStatusChange(); + if (device != null) { + _currentDevice = device; + _deviceRegisteredController.add(device); + } + return await _deviceService.getNotificationStatus(); + } + + /// Open the OS settings page for this app so the user can grant the + /// notification permission manually. + /// + /// Returns true if the settings page was opened. + Future openNotificationSettings() async { + _ensureInitialized(); + return await _deviceService.openNotificationSettings(); + } + /// Check if SDK is initialized static bool get isInitialized => _isInitialized; diff --git a/lib/src/services/device_service.dart b/lib/src/services/device_service.dart index 40ce481..deff9ff 100644 --- a/lib/src/services/device_service.dart +++ b/lib/src/services/device_service.dart @@ -30,6 +30,8 @@ class DeviceService { final Future> Function()? getDeviceInfoOverride; @visibleForTesting final Future Function()? getFcmTokenOverride; + @visibleForTesting + final Future Function()? openAppSettingsOverride; DeviceService( this._apiClient, @@ -37,6 +39,7 @@ class DeviceService { this.isPushNotificationEnabledOverride, this.getDeviceInfoOverride, this.getFcmTokenOverride, + this.openAppSettingsOverride, }); /// Register or update device automatically @@ -175,8 +178,11 @@ class DeviceService { /// Get FCM token Future _getFcmToken() async { - if (getFcmTokenOverride != null) { - return getFcmTokenOverride!(); + // Testing override takes precedence; otherwise honor an integrator-supplied + // override from PushFireConfig (e.g. an APNS-aware fetcher on iOS). + final override = getFcmTokenOverride ?? _config.getFcmTokenOverride; + if (override != null) { + return override(); } try { final messaging = FirebaseMessaging.instance; @@ -224,6 +230,28 @@ class DeviceService { } else { PushFireLogger.info( 'Automatic permission request disabled in configuration'); + // Fix B: even with the prompt disabled, optionally ensure iOS registers + // for remote notifications so an APNS token can arrive (without showing + // the authorization dialog). + if (Platform.isIOS && _config.iosRegisterWithoutPrompt) { + await _ensureIosApnsRegistrationWithoutPrompt(messaging); + } + } + + // Fix A: on iOS the APNS token is delivered asynchronously by Apple after + // registerForRemoteNotifications. Calling getToken() before it lands + // throws [firebase_messaging/apns-token-not-set]. Wait for it first, and + // if it never arrives (simulator/offline, or registration was never + // triggered) skip getToken() and return null so auto-registration doesn't + // hard-fail — the device will register later via onTokenRefresh. + if (Platform.isIOS) { + final apnsToken = await _waitForApnsToken(messaging); + if (apnsToken == null) { + PushFireLogger.warning( + 'APNS token not available yet - skipping FCM token fetch. ' + 'Device will register once the token arrives (onTokenRefresh).'); + return null; + } } // Get token regardless of permission status (for manual permission grants) @@ -239,6 +267,60 @@ class DeviceService { } } + /// iOS only: poll for the APNS token that Apple delivers asynchronously after + /// `registerForRemoteNotifications`. Returns null if it does not arrive within + /// the retry window (e.g. simulator, offline, or registration never + /// triggered). + Future _waitForApnsToken( + FirebaseMessaging messaging, { + int maxRetries = 10, + Duration interval = const Duration(milliseconds: 500), + }) async { + var apns = await messaging.getAPNSToken(); + var retries = 0; + while (apns == null && retries < maxRetries) { + await Future.delayed(interval); + apns = await messaging.getAPNSToken(); + retries++; + } + if (apns == null) { + PushFireLogger.warning( + 'APNS token still null after ${maxRetries * interval.inMilliseconds}ms'); + } + return apns; + } + + /// iOS only: request *provisional* authorization so the OS calls + /// `registerForRemoteNotifications` (making an APNS token available) WITHOUT + /// showing the interruptive permission dialog. + /// + /// Provisional authorization delivers notifications quietly to Notification + /// Center; it is not the same as "no authorization". Only acts while the user + /// has not yet made an explicit choice (status notDetermined) so it never + /// overrides an existing decision. + Future _ensureIosApnsRegistrationWithoutPrompt( + FirebaseMessaging messaging) async { + try { + final settings = await messaging.getNotificationSettings(); + if (settings.authorizationStatus == AuthorizationStatus.notDetermined) { + PushFireLogger.info( + 'Requesting provisional authorization to trigger APNS registration without a prompt'); + await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: true, + ); + } else { + PushFireLogger.info( + 'Skipping provisional registration - authorization already ${settings.authorizationStatus}'); + } + } catch (e) { + PushFireLogger.warning( + 'Failed to ensure iOS APNS registration without prompt', e); + } + } + /// Request permission using Firebase Messaging (for iOS and older Android) Future _requestPermissionWithFirebaseMessaging( FirebaseMessaging messaging) async { @@ -505,6 +587,27 @@ class DeviceService { } } + /// Open the OS settings page for this app. + /// + /// Use this when the OS notification permission has been permanently denied + /// and re-requesting no longer shows a system prompt — the only way for the + /// user to grant it is through the settings app. + /// + /// Returns true if the settings page was opened successfully. + Future openNotificationSettings() async { + if (openAppSettingsOverride != null) { + return openAppSettingsOverride!(); + } + try { + final opened = await openAppSettings(); + PushFireLogger.info('Opened app settings: $opened'); + return opened; + } catch (e) { + PushFireLogger.warning('Failed to open app settings', e); + return false; + } + } + /// Get stored device ID Future getDeviceId() async { final prefs = await SharedPreferences.getInstance(); diff --git a/pubspec.lock b/pubspec.lock index cfda5a1..cb0f1f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -316,18 +316,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -641,10 +641,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: @@ -790,5 +790,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 30b8aec..532dfe0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A lightweight push notification tracking SDK for Firebase. homepage: https://github.com/FlywheelStudio/pushfire_sdk/blob/main/README.md repository: https://github.com/FlywheelStudio/pushfire_sdk -version: 0.2.1 +version: 0.3.0 environment: sdk: '>=3.3.1 <4.0.0' diff --git a/test/config/pushfire_config_test.dart b/test/config/pushfire_config_test.dart index 19b4be5..4258f02 100644 --- a/test/config/pushfire_config_test.dart +++ b/test/config/pushfire_config_test.dart @@ -66,6 +66,14 @@ void main() { test('requestNotificationPermission defaults to true', () { expect(config.requestNotificationPermission, true); }); + + test('iosRegisterWithoutPrompt defaults to false', () { + expect(config.iosRegisterWithoutPrompt, false); + }); + + test('getFcmTokenOverride defaults to null', () { + expect(config.getFcmTokenOverride, isNull); + }); }); group('construction with all parameters', () { @@ -197,6 +205,31 @@ void main() { expect(copy.apiKey, original.apiKey); }); + test('copies with updated iosRegisterWithoutPrompt', () { + final copy = original.copyWith(iosRegisterWithoutPrompt: true); + expect(copy.iosRegisterWithoutPrompt, true); + expect(copy.apiKey, original.apiKey); + }); + + test('preserves iosRegisterWithoutPrompt when not overridden', () { + final base = original.copyWith(iosRegisterWithoutPrompt: true); + final copy = base.copyWith(apiKey: 'other'); + expect(copy.iosRegisterWithoutPrompt, true); + }); + + test('copies with updated getFcmTokenOverride', () { + Future fetcher() async => 'overridden-token'; + final copy = original.copyWith(getFcmTokenOverride: fetcher); + expect(copy.getFcmTokenOverride, isNotNull); + }); + + test('preserves getFcmTokenOverride when not overridden', () { + Future fetcher() async => 'overridden-token'; + final base = original.copyWith(getFcmTokenOverride: fetcher); + final copy = base.copyWith(apiKey: 'other'); + expect(copy.getFcmTokenOverride, same(fetcher)); + }); + test('copies with multiple parameters updated at once', () { final copy = original.copyWith( apiKey: 'multi-key', diff --git a/test/services/device_service_fcm_token_test.dart b/test/services/device_service_fcm_token_test.dart new file mode 100644 index 0000000..c201465 --- /dev/null +++ b/test/services/device_service_fcm_token_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pushfire_sdk/src/api/pushfire_api_client.dart'; +import 'package:pushfire_sdk/src/config/pushfire_config.dart'; +import 'package:pushfire_sdk/src/exceptions/pushfire_exceptions.dart'; +import 'package:pushfire_sdk/src/services/device_service.dart'; + +/// Fake API client that records calls instead of making HTTP requests. +class FakeApiClient extends PushFireApiClient { + final List> postCalls = []; + final List> patchCalls = []; + Map postResponse = {'id': 'test-device-id'}; + + FakeApiClient() + : super(const PushFireConfig(apiKey: 'test', baseUrl: 'http://test/')); + + @override + Future> post( + String endpoint, Map data) async { + postCalls.add({'endpoint': endpoint, 'data': data}); + return postResponse; + } + + @override + Future> patch( + String endpoint, Map data) async { + patchCalls.add({'endpoint': endpoint, 'data': data}); + return {'success': true}; + } +} + +const _testDeviceInfo = { + 'os': 'ios', + 'osVersion': '17.0', + 'language': 'en', + 'manufacturer': 'Apple', + 'model': 'iPhone', + 'appVersion': '1.0.0', +}; + +/// Builds a DeviceService that avoids real platform/Firebase calls by stubbing +/// OS permission and device info, but leaves the FCM-token resolution under +/// test. [configOverride] exercises the PushFireConfig.getFcmTokenOverride hook; +/// [constructorOverride] exercises the @visibleForTesting constructor hook. +DeviceService buildService( + FakeApiClient api, { + Future Function()? configOverride, + Future Function()? constructorOverride, +}) { + return DeviceService( + api, + PushFireConfig( + apiKey: 'test', + baseUrl: 'http://test/', + getFcmTokenOverride: configOverride, + ), + isPushNotificationEnabledOverride: () async => true, + getDeviceInfoOverride: () async => _testDeviceInfo, + getFcmTokenOverride: constructorOverride, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FCM token override resolution', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('config.getFcmTokenOverride is used when no constructor override', + () async { + final api = FakeApiClient(); + final service = buildService( + api, + configOverride: () async => 'config-token', + ); + + final device = await service.registerDevice(); + + expect(device.fcmToken, 'config-token'); + expect(api.postCalls, hasLength(1)); + expect(api.postCalls.first['data']['data']['fcmToken'], 'config-token'); + }); + + test('constructor override takes precedence over config override', + () async { + final api = FakeApiClient(); + final service = buildService( + api, + configOverride: () async => 'config-token', + constructorOverride: () async => 'constructor-token', + ); + + final device = await service.registerDevice(); + + expect(device.fcmToken, 'constructor-token'); + expect( + api.postCalls.first['data']['data']['fcmToken'], 'constructor-token'); + }); + + test( + 'registration fails cleanly and skips the server when the override ' + 'returns null', () async { + final api = FakeApiClient(); + final service = buildService( + api, + configOverride: () async => null, + ); + + await expectLater( + service.registerDevice(), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Failed to get FCM token', + )), + ); + // Device must not be registered when there is no token. + expect(api.postCalls, isEmpty); + expect(await service.getDeviceId(), isNull); + }); + }); +} diff --git a/test/services/device_service_notification_preference_test.dart b/test/services/device_service_notification_preference_test.dart index 2d790f7..051f932 100644 --- a/test/services/device_service_notification_preference_test.dart +++ b/test/services/device_service_notification_preference_test.dart @@ -55,6 +55,7 @@ DeviceService createTestService({ FakeApiClient? apiClient, TestPlatformState? platform, String? fcmToken = 'test-fcm-token', + Future Function()? openAppSettingsOverride, }) { final api = apiClient ?? FakeApiClient(); final state = platform ?? TestPlatformState(); @@ -64,6 +65,7 @@ DeviceService createTestService({ isPushNotificationEnabledOverride: () async => state.osPermission, getDeviceInfoOverride: () async => _testDeviceInfo, getFcmTokenOverride: () async => fcmToken, + openAppSettingsOverride: openAppSettingsOverride, ); } @@ -474,6 +476,34 @@ void main() { }); }); + group('openNotificationSettings', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('returns true when settings opened', () async { + var called = false; + final service = createTestService(openAppSettingsOverride: () async { + called = true; + return true; + }); + + final result = await service.openNotificationSettings(); + + expect(called, true); + expect(result, true); + }); + + test('returns false when settings could not be opened', () async { + final service = + createTestService(openAppSettingsOverride: () async => false); + + final result = await service.openNotificationSettings(); + + expect(result, false); + }); + }); + group('clearDeviceData', () { test('clears notification preference key', () async { SharedPreferences.setMockInitialValues({