From 0015c4e5f3a58f4a528b0aa44761077ac8b206af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 17:45:11 +0000 Subject: [PATCH 01/11] Update Android SDK to 5.9.0 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 9efac32c..6cb7fd94 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,5 +38,5 @@ android { } dependencies { - implementation 'com.onesignal:OneSignal:5.8.1' + implementation 'com.onesignal:OneSignal:5.9.0' } From 7dd2e300d0409438dd27eb515deb42971522ce7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 17:45:12 +0000 Subject: [PATCH 02/11] Release 5.5.5 --- android/build.gradle | 2 +- .../src/main/java/com/onesignal/flutter/OneSignalPlugin.java | 2 +- ios/onesignal_flutter.podspec | 2 +- .../Sources/onesignal_flutter/OneSignalPlugin.m | 2 +- pubspec.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 6cb7fd94..9a3e84ea 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ group 'com.onesignal.flutter' -version '5.5.4' +version '5.5.5' buildscript { repositories { diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java index eb55ede7..47933874 100644 --- a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java +++ b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java @@ -24,7 +24,7 @@ private void init(Context context, BinaryMessenger messenger) { this.messenger = messenger; OneSignalWrapper.setSdkType("flutter"); // Keep in sync with pubspec.yaml version - OneSignalWrapper.setSdkVersion("050504"); + OneSignalWrapper.setSdkVersion("050505"); channel = new MethodChannel(messenger, "OneSignal"); channel.setMethodCallHandler(this); diff --git a/ios/onesignal_flutter.podspec b/ios/onesignal_flutter.podspec index 4f130672..753c4b99 100644 --- a/ios/onesignal_flutter.podspec +++ b/ios/onesignal_flutter.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'onesignal_flutter' - s.version = '5.5.4' + s.version = '5.5.5' s.summary = 'The OneSignal Flutter SDK' s.description = 'Allows you to easily add OneSignal to your flutter projects, to make sending and handling push notifications easy' s.homepage = 'https://www.onesignal.com' diff --git a/ios/onesignal_flutter/Sources/onesignal_flutter/OneSignalPlugin.m b/ios/onesignal_flutter/Sources/onesignal_flutter/OneSignalPlugin.m index ffe5034b..e4a08de4 100644 --- a/ios/onesignal_flutter/Sources/onesignal_flutter/OneSignalPlugin.m +++ b/ios/onesignal_flutter/Sources/onesignal_flutter/OneSignalPlugin.m @@ -56,7 +56,7 @@ + (instancetype)sharedInstance { + (void)registerWithRegistrar:(NSObject *)registrar { OneSignalWrapper.sdkType = @"flutter"; - OneSignalWrapper.sdkVersion = @"050504"; + OneSignalWrapper.sdkVersion = @"050505"; [OneSignal initialize:nil withLaunchOptions:nil]; OneSignalPlugin.sharedInstance.channel = diff --git a/pubspec.yaml b/pubspec.yaml index 733f0383..43638661 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: onesignal_flutter description: OneSignal is a free push notification service for mobile apps. This plugin makes it easy to integrate your flutter app with OneSignal -version: 5.5.4 +version: 5.5.5 homepage: https://github.com/OneSignal/OneSignal-Flutter-SDK # Uses rps package for scripts From 6fd7c22cab7afefa92519be2355d1851d2cc862e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 00:05:43 +0000 Subject: [PATCH 03/11] Update Android SDK to 5.9.1 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 9a3e84ea..7b257036 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,5 +38,5 @@ android { } dependencies { - implementation 'com.onesignal:OneSignal:5.9.0' + implementation 'com.onesignal:OneSignal:5.9.1' } From f3c0493f0cdf9a1be35d4d269834e9a2f8003a2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 15 May 2026 22:40:40 +0000 Subject: [PATCH 04/11] Update Android SDK to 5.9.2 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 7b257036..ac809f50 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,5 +38,5 @@ android { } dependencies { - implementation 'com.onesignal:OneSignal:5.9.1' + implementation 'com.onesignal:OneSignal:5.9.2' } From 647583c897eee4b608563ad9e81653c388d7be8a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:28:38 -0700 Subject: [PATCH 05/11] ci: handle spaced IPA filename in build-ios The unquoted glob in `cp build/ios/ipa/*.ipa Runner.ipa` word-splits when Flutter produces an IPA whose filename contains a space (e.g. "OneSignal Demo.ipa"), causing `cp` to expect the destination to be a directory and fail with `cp: build/ios/ipa/Runner.ipa is not a directory`. Co-authored-by: Cursor --- .github/workflows/e2e.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d1312eab..fab940d1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -110,7 +110,9 @@ jobs: flutter build ipa --release \ --export-options-plist=ios/ExportOptions.plist \ --split-debug-info=build/symbols - cp build/ios/ipa/*.ipa build/ios/ipa/Runner.ipa + for f in build/ios/ipa/*.ipa; do + cp "$f" build/ios/ipa/Runner.ipa + done - name: Verify aps-environment in IPA working-directory: examples/demo From 29fdd3d7427b50afa1a913fc089ae37b2100fa73 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:43:52 -0700 Subject: [PATCH 06/11] ci: skip self-copy when IPA is already named Runner.ipa When Flutter produces `build/ios/ipa/Runner.ipa` directly, the previous `for f in *.ipa; cp "$f" Runner.ipa` ran `cp Runner.ipa Runner.ipa` and failed with "are identical (not copied)". Detect the IPA via a nullglob array, error out if none was produced, and only copy when the source and destination differ. Still handles filenames containing spaces. Co-authored-by: Cursor --- .github/workflows/e2e.yml | 14 +++++++++++--- examples/demo/lib/utils/mask_value.dart | 2 +- .../demo/lib/widgets/sections/push_section.dart | 2 +- .../demo/lib/widgets/sections/user_section.dart | 2 +- examples/demo_pods/lib/utils/mask_value.dart | 2 +- .../lib/widgets/sections/push_section.dart | 2 +- .../lib/widgets/sections/user_section.dart | 2 +- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fab940d1..c335d267 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -110,9 +110,17 @@ jobs: flutter build ipa --release \ --export-options-plist=ios/ExportOptions.plist \ --split-debug-info=build/symbols - for f in build/ios/ipa/*.ipa; do - cp "$f" build/ios/ipa/Runner.ipa - done + shopt -s nullglob + ipas=(build/ios/ipa/*.ipa) + if [ ${#ipas[@]} -eq 0 ]; then + echo "::error::No IPA was produced in build/ios/ipa" + exit 1 + fi + src="${ipas[0]}" + dst="build/ios/ipa/Runner.ipa" + if [ "$src" != "$dst" ]; then + cp "$src" "$dst" + fi - name: Verify aps-environment in IPA working-directory: examples/demo diff --git a/examples/demo/lib/utils/mask_value.dart b/examples/demo/lib/utils/mask_value.dart index 0bd0c198..f18cd6c8 100644 --- a/examples/demo/lib/utils/mask_value.dart +++ b/examples/demo/lib/utils/mask_value.dart @@ -7,7 +7,7 @@ final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; /// real app/push IDs don't leak into screenshots or Appium element captures. /// Returns [value] unchanged otherwise. String maskValue(String value) { - if (_isE2E) { + if (_isE2E && value != '—') { return _maskChar * value.length; } return value; diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index 83e810aa..ddeb0607 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -41,7 +41,7 @@ class PushSection extends StatelessWidget { identifier: 'push_id_value', container: true, child: SelectableText( - maskValue(vm.pushSubscriptionId ?? 'N/A'), + maskValue(vm.pushSubscriptionId ?? '—'), style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo/lib/widgets/sections/user_section.dart b/examples/demo/lib/widgets/sections/user_section.dart index c7e70053..d823968b 100644 --- a/examples/demo/lib/widgets/sections/user_section.dart +++ b/examples/demo/lib/widgets/sections/user_section.dart @@ -60,7 +60,7 @@ class UserSection extends StatelessWidget { identifier: 'user_external_id_value', container: true, child: SelectableText( - vm.isLoggedIn ? (vm.externalUserId ?? '') : '–', + vm.isLoggedIn ? (vm.externalUserId ?? '') : '—', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo_pods/lib/utils/mask_value.dart b/examples/demo_pods/lib/utils/mask_value.dart index 0bd0c198..f18cd6c8 100644 --- a/examples/demo_pods/lib/utils/mask_value.dart +++ b/examples/demo_pods/lib/utils/mask_value.dart @@ -7,7 +7,7 @@ final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; /// real app/push IDs don't leak into screenshots or Appium element captures. /// Returns [value] unchanged otherwise. String maskValue(String value) { - if (_isE2E) { + if (_isE2E && value != '—') { return _maskChar * value.length; } return value; diff --git a/examples/demo_pods/lib/widgets/sections/push_section.dart b/examples/demo_pods/lib/widgets/sections/push_section.dart index 83e810aa..ddeb0607 100644 --- a/examples/demo_pods/lib/widgets/sections/push_section.dart +++ b/examples/demo_pods/lib/widgets/sections/push_section.dart @@ -41,7 +41,7 @@ class PushSection extends StatelessWidget { identifier: 'push_id_value', container: true, child: SelectableText( - maskValue(vm.pushSubscriptionId ?? 'N/A'), + maskValue(vm.pushSubscriptionId ?? '—'), style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo_pods/lib/widgets/sections/user_section.dart b/examples/demo_pods/lib/widgets/sections/user_section.dart index b9331413..a842e105 100644 --- a/examples/demo_pods/lib/widgets/sections/user_section.dart +++ b/examples/demo_pods/lib/widgets/sections/user_section.dart @@ -60,7 +60,7 @@ class UserSection extends StatelessWidget { identifier: 'user_external_id_value', container: true, child: SelectableText( - vm.isLoggedIn ? (vm.externalUserId ?? '') : '–', + vm.isLoggedIn ? (vm.externalUserId ?? '') : '—', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), From d6cfa4ac3134ee4cf79fad693a363317e8dad61f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 20 May 2026 17:42:03 -0700 Subject: [PATCH 07/11] fix(examples): retry on invalid_player_ids --- .../lib/services/onesignal_api_service.dart | 130 ++++++++++-------- .../lib/services/onesignal_api_service.dart | 130 ++++++++++-------- 2 files changed, 150 insertions(+), 110 deletions(-) diff --git a/examples/demo/lib/services/onesignal_api_service.dart b/examples/demo/lib/services/onesignal_api_service.dart index 94ab0c85..b4a7c8f4 100644 --- a/examples/demo/lib/services/onesignal_api_service.dart +++ b/examples/demo/lib/services/onesignal_api_service.dart @@ -35,41 +35,26 @@ class OneSignalApiService { NotificationType type, String subscriptionId, ) async { - try { - final body = { - 'app_id': _appId, - 'include_subscription_ids': [subscriptionId], - 'headings': {'en': type.title}, - 'contents': {'en': type.body}, - }; - if (type.bigPicture != null) { - body['big_picture'] = type.bigPicture; - } - if (type.iosAttachments != null) { - body['ios_attachments'] = type.iosAttachments; - } - if (type.iosSound != null) { - body['ios_sound'] = type.iosSound; - } - if (type.useAndroidChannel) { - body['android_channel_id'] = _resolveAndroidChannelId(); - } - - final response = await http.post( - Uri.parse('https://onesignal.com/api/v1/notifications'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.onesignal.v1+json', - }, - body: jsonEncode(body), - ); - - debugPrint('Send notification response: ${response.statusCode}'); - return response.statusCode == 200; - } catch (e) { - debugPrint('Send notification error: $e'); - return false; + final body = { + 'app_id': _appId, + 'include_subscription_ids': [subscriptionId], + 'headings': {'en': type.title}, + 'contents': {'en': type.body}, + }; + if (type.bigPicture != null) { + body['big_picture'] = type.bigPicture; + } + if (type.iosAttachments != null) { + body['ios_attachments'] = type.iosAttachments; } + if (type.iosSound != null) { + body['ios_sound'] = type.iosSound; + } + if (type.useAndroidChannel) { + body['android_channel_id'] = _resolveAndroidChannelId(); + } + + return _postNotification(body); } Future sendCustomNotification( @@ -77,29 +62,64 @@ class OneSignalApiService { String body, String subscriptionId, ) async { - try { - final payload = { - 'app_id': _appId, - 'include_subscription_ids': [subscriptionId], - 'headings': {'en': title}, - 'contents': {'en': body}, - }; - - final response = await http.post( - Uri.parse('https://onesignal.com/api/v1/notifications'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.onesignal.v1+json', - }, - body: jsonEncode(payload), - ); + final payload = { + 'app_id': _appId, + 'include_subscription_ids': [subscriptionId], + 'headings': {'en': title}, + 'contents': {'en': body}, + }; + + return _postNotification(payload); + } - debugPrint('Send custom notification response: ${response.statusCode}'); - return response.statusCode == 200; - } catch (e) { - debugPrint('Send custom notification error: $e'); - return false; + // Retry on `invalid_player_ids` to absorb the brief race where the + // subscription has been created locally but is not yet visible to the + // /notifications endpoint. + Future _postNotification(Map payload) async { + const maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + try { + final response = await http.post( + Uri.parse('https://onesignal.com/api/v1/notifications'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.onesignal.v1+json', + }, + body: jsonEncode(payload), + ); + + debugPrint('Send notification response: ${response.statusCode}'); + if (response.statusCode != 200) { + return false; + } + + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final errors = decoded['errors']; + if (errors is Map) { + final invalidIds = errors['invalid_player_ids']; + if (invalidIds is List && invalidIds.isNotEmpty) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(seconds: 3 * attempt)); + continue; + } + debugPrint( + 'Send notification failed: invalid_player_ids $invalidIds', + ); + return false; + } + } + } + + return true; + } catch (e) { + debugPrint('Send notification error: $e'); + return false; + } } + + return false; } Future updateLiveActivity( diff --git a/examples/demo_pods/lib/services/onesignal_api_service.dart b/examples/demo_pods/lib/services/onesignal_api_service.dart index 94ab0c85..b4a7c8f4 100644 --- a/examples/demo_pods/lib/services/onesignal_api_service.dart +++ b/examples/demo_pods/lib/services/onesignal_api_service.dart @@ -35,41 +35,26 @@ class OneSignalApiService { NotificationType type, String subscriptionId, ) async { - try { - final body = { - 'app_id': _appId, - 'include_subscription_ids': [subscriptionId], - 'headings': {'en': type.title}, - 'contents': {'en': type.body}, - }; - if (type.bigPicture != null) { - body['big_picture'] = type.bigPicture; - } - if (type.iosAttachments != null) { - body['ios_attachments'] = type.iosAttachments; - } - if (type.iosSound != null) { - body['ios_sound'] = type.iosSound; - } - if (type.useAndroidChannel) { - body['android_channel_id'] = _resolveAndroidChannelId(); - } - - final response = await http.post( - Uri.parse('https://onesignal.com/api/v1/notifications'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.onesignal.v1+json', - }, - body: jsonEncode(body), - ); - - debugPrint('Send notification response: ${response.statusCode}'); - return response.statusCode == 200; - } catch (e) { - debugPrint('Send notification error: $e'); - return false; + final body = { + 'app_id': _appId, + 'include_subscription_ids': [subscriptionId], + 'headings': {'en': type.title}, + 'contents': {'en': type.body}, + }; + if (type.bigPicture != null) { + body['big_picture'] = type.bigPicture; + } + if (type.iosAttachments != null) { + body['ios_attachments'] = type.iosAttachments; } + if (type.iosSound != null) { + body['ios_sound'] = type.iosSound; + } + if (type.useAndroidChannel) { + body['android_channel_id'] = _resolveAndroidChannelId(); + } + + return _postNotification(body); } Future sendCustomNotification( @@ -77,29 +62,64 @@ class OneSignalApiService { String body, String subscriptionId, ) async { - try { - final payload = { - 'app_id': _appId, - 'include_subscription_ids': [subscriptionId], - 'headings': {'en': title}, - 'contents': {'en': body}, - }; - - final response = await http.post( - Uri.parse('https://onesignal.com/api/v1/notifications'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.onesignal.v1+json', - }, - body: jsonEncode(payload), - ); + final payload = { + 'app_id': _appId, + 'include_subscription_ids': [subscriptionId], + 'headings': {'en': title}, + 'contents': {'en': body}, + }; + + return _postNotification(payload); + } - debugPrint('Send custom notification response: ${response.statusCode}'); - return response.statusCode == 200; - } catch (e) { - debugPrint('Send custom notification error: $e'); - return false; + // Retry on `invalid_player_ids` to absorb the brief race where the + // subscription has been created locally but is not yet visible to the + // /notifications endpoint. + Future _postNotification(Map payload) async { + const maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + try { + final response = await http.post( + Uri.parse('https://onesignal.com/api/v1/notifications'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.onesignal.v1+json', + }, + body: jsonEncode(payload), + ); + + debugPrint('Send notification response: ${response.statusCode}'); + if (response.statusCode != 200) { + return false; + } + + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final errors = decoded['errors']; + if (errors is Map) { + final invalidIds = errors['invalid_player_ids']; + if (invalidIds is List && invalidIds.isNotEmpty) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(seconds: 3 * attempt)); + continue; + } + debugPrint( + 'Send notification failed: invalid_player_ids $invalidIds', + ); + return false; + } + } + } + + return true; + } catch (e) { + debugPrint('Send notification error: $e'); + return false; + } } + + return false; } Future updateLiveActivity( From 2729bcff8844684a9440a69a30706321ff9a19ee Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 20 May 2026 19:42:25 -0700 Subject: [PATCH 08/11] refactor(examples): remove E2E value masking --- .github/actions/create-demo-env/action.yml | 5 ----- examples/demo/lib/utils/mask_value.dart | 14 -------------- .../demo/lib/widgets/sections/app_section.dart | 3 +-- .../demo/lib/widgets/sections/push_section.dart | 3 +-- examples/demo_pods/lib/utils/mask_value.dart | 14 -------------- .../lib/widgets/sections/app_section.dart | 3 +-- .../lib/widgets/sections/push_section.dart | 3 +-- 7 files changed, 4 insertions(+), 41 deletions(-) delete mode 100644 examples/demo/lib/utils/mask_value.dart delete mode 100644 examples/demo_pods/lib/utils/mask_value.dart diff --git a/.github/actions/create-demo-env/action.yml b/.github/actions/create-demo-env/action.yml index fc416949..adc9a6a9 100644 --- a/.github/actions/create-demo-env/action.yml +++ b/.github/actions/create-demo-env/action.yml @@ -7,10 +7,6 @@ inputs: onesignal-api-key: description: "OneSignal REST API key for the demo app" required: true - e2e-mode: - description: "Whether to enable E2E_MODE in the demo app" - required: false - default: "true" runs: using: "composite" steps: @@ -21,5 +17,4 @@ runs: { echo "ONESIGNAL_APP_ID=${{ inputs.onesignal-app-id }}" echo "ONESIGNAL_API_KEY=${{ inputs.onesignal-api-key }}" - echo "E2E_MODE=${{ inputs.e2e-mode }}" } > .env diff --git a/examples/demo/lib/utils/mask_value.dart b/examples/demo/lib/utils/mask_value.dart deleted file mode 100644 index f18cd6c8..00000000 --- a/examples/demo/lib/utils/mask_value.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -const String _maskChar = '\u2022'; -final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; - -/// Replaces [value] with a mask of equal length when running in E2E mode so -/// real app/push IDs don't leak into screenshots or Appium element captures. -/// Returns [value] unchanged otherwise. -String maskValue(String value) { - if (_isE2E && value != '—') { - return _maskChar * value.length; - } - return value; -} diff --git a/examples/demo/lib/widgets/sections/app_section.dart b/examples/demo/lib/widgets/sections/app_section.dart index f9f424ae..64f86205 100644 --- a/examples/demo/lib/widgets/sections/app_section.dart +++ b/examples/demo/lib/widgets/sections/app_section.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../theme.dart'; -import '../../utils/mask_value.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../section_card.dart'; import '../toggle_row.dart'; @@ -38,7 +37,7 @@ class AppSection extends StatelessWidget { identifier: 'app_id_value', container: true, child: SelectableText( - maskValue(vm.appId), + vm.appId, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index ddeb0607..ee9b3aee 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../theme.dart'; -import '../../utils/mask_value.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../action_button.dart'; import '../section_card.dart'; @@ -41,7 +40,7 @@ class PushSection extends StatelessWidget { identifier: 'push_id_value', container: true, child: SelectableText( - maskValue(vm.pushSubscriptionId ?? '—'), + vm.pushSubscriptionId ?? '—', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo_pods/lib/utils/mask_value.dart b/examples/demo_pods/lib/utils/mask_value.dart deleted file mode 100644 index f18cd6c8..00000000 --- a/examples/demo_pods/lib/utils/mask_value.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -const String _maskChar = '\u2022'; -final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; - -/// Replaces [value] with a mask of equal length when running in E2E mode so -/// real app/push IDs don't leak into screenshots or Appium element captures. -/// Returns [value] unchanged otherwise. -String maskValue(String value) { - if (_isE2E && value != '—') { - return _maskChar * value.length; - } - return value; -} diff --git a/examples/demo_pods/lib/widgets/sections/app_section.dart b/examples/demo_pods/lib/widgets/sections/app_section.dart index f9f424ae..64f86205 100644 --- a/examples/demo_pods/lib/widgets/sections/app_section.dart +++ b/examples/demo_pods/lib/widgets/sections/app_section.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../theme.dart'; -import '../../utils/mask_value.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../section_card.dart'; import '../toggle_row.dart'; @@ -38,7 +37,7 @@ class AppSection extends StatelessWidget { identifier: 'app_id_value', container: true, child: SelectableText( - maskValue(vm.appId), + vm.appId, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), diff --git a/examples/demo_pods/lib/widgets/sections/push_section.dart b/examples/demo_pods/lib/widgets/sections/push_section.dart index ddeb0607..ee9b3aee 100644 --- a/examples/demo_pods/lib/widgets/sections/push_section.dart +++ b/examples/demo_pods/lib/widgets/sections/push_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../theme.dart'; -import '../../utils/mask_value.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../action_button.dart'; import '../section_card.dart'; @@ -41,7 +40,7 @@ class PushSection extends StatelessWidget { identifier: 'push_id_value', container: true, child: SelectableText( - maskValue(vm.pushSubscriptionId ?? '—'), + vm.pushSubscriptionId ?? '—', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), From a6e07276632f826898e2710e406ac051fd7df719 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 20 May 2026 20:00:03 -0700 Subject: [PATCH 09/11] chore(demo): display OneSignal ID in push section Co-authored-by: Cursor --- .../demo/lib/viewmodels/app_viewmodel.dart | 10 +++++++- .../lib/widgets/sections/push_section.dart | 23 +++++++++++++++++++ .../lib/viewmodels/app_viewmodel.dart | 9 +++++++- .../lib/widgets/sections/push_section.dart | 23 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index fb6a719c..d62c5e53 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -64,6 +64,9 @@ class AppViewModel extends ChangeNotifier { bool get isLoggedIn => _externalUserId != null; + String? _oneSignalId; + String? get oneSignalId => _oneSignalId; + // Push state String? _pushSubscriptionId; String? get pushSubscriptionId => _pushSubscriptionId; @@ -139,9 +142,11 @@ class AppViewModel extends ChangeNotifier { _pushEnabled = OneSignal.User.pushSubscription.optedIn ?? false; _hasNotificationPermission = OneSignal.Notifications.permission; + final onesignalId = await OneSignal.User.getOnesignalId(); + _oneSignalId = onesignalId; + notifyListeners(); - final onesignalId = await OneSignal.User.getOnesignalId(); if (onesignalId == null) return; // fetchUserDataFromApi owns _isLoading + notifyListeners. @@ -171,6 +176,9 @@ class AppViewModel extends ChangeNotifier { 'User changed: onesignalId=${nextOnesignalId ?? 'null'}, externalId=${state.current.externalId ?? 'null'}', ); + _oneSignalId = nextOnesignalId; + notifyListeners(); + // Drive the post-login fetch from the observer so it runs only once the // SDK has actually assigned a new onesignalId. Logout clears it to null; // skip the fetch in that case (logoutUser already clears local lists). diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index ee9b3aee..e9fdd12e 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -28,6 +28,29 @@ class PushSection extends StatelessWidget { padding: AppSpacing.cardPadding, child: Column( children: [ + Row( + children: [ + Text( + 'OneSignal ID', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 12), + Expanded( + child: Semantics( + identifier: 'onesignal_id_value', + container: true, + child: SelectableText( + vm.oneSignalId ?? '—', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + textAlign: TextAlign.end, + ), + ), + ), + ], + ), + const Divider(), Row( children: [ Text( diff --git a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart index ba7edb2c..2ae61574 100644 --- a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart @@ -59,6 +59,9 @@ class AppViewModel extends ChangeNotifier { bool get isLoggedIn => _externalUserId != null; + String? _oneSignalId; + String? get oneSignalId => _oneSignalId; + // Push state String? _pushSubscriptionId; String? get pushSubscriptionId => _pushSubscriptionId; @@ -134,9 +137,11 @@ class AppViewModel extends ChangeNotifier { _pushEnabled = OneSignal.User.pushSubscription.optedIn ?? false; _hasNotificationPermission = OneSignal.Notifications.permission; + final onesignalId = await OneSignal.User.getOnesignalId(); + _oneSignalId = onesignalId; + notifyListeners(); - final onesignalId = await OneSignal.User.getOnesignalId(); if (onesignalId == null) return; _isLoading = true; @@ -172,6 +177,8 @@ class AppViewModel extends ChangeNotifier { debugPrint( 'User changed: onesignalId=${state.current.onesignalId ?? 'null'}, externalId=${state.current.externalId ?? 'null'}', ); + _oneSignalId = state.current.onesignalId; + notifyListeners(); }); } diff --git a/examples/demo_pods/lib/widgets/sections/push_section.dart b/examples/demo_pods/lib/widgets/sections/push_section.dart index ee9b3aee..e9fdd12e 100644 --- a/examples/demo_pods/lib/widgets/sections/push_section.dart +++ b/examples/demo_pods/lib/widgets/sections/push_section.dart @@ -28,6 +28,29 @@ class PushSection extends StatelessWidget { padding: AppSpacing.cardPadding, child: Column( children: [ + Row( + children: [ + Text( + 'OneSignal ID', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 12), + Expanded( + child: Semantics( + identifier: 'onesignal_id_value', + container: true, + child: SelectableText( + vm.oneSignalId ?? '—', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + textAlign: TextAlign.end, + ), + ), + ), + ], + ), + const Divider(), Row( children: [ Text( From b67a677d6b9084d7d5c38ac922c1e5c3f060d012 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 22 May 2026 14:07:51 -0700 Subject: [PATCH 10/11] fix(examples): improve retry backoff and remove OneSignal ID row --- .../lib/services/onesignal_api_service.dart | 17 ++++++++------ .../lib/widgets/sections/push_section.dart | 23 ------------------- .../lib/services/onesignal_api_service.dart | 17 ++++++++------ .../lib/widgets/sections/push_section.dart | 23 ------------------- 4 files changed, 20 insertions(+), 60 deletions(-) diff --git a/examples/demo/lib/services/onesignal_api_service.dart b/examples/demo/lib/services/onesignal_api_service.dart index b4a7c8f4..5d98029c 100644 --- a/examples/demo/lib/services/onesignal_api_service.dart +++ b/examples/demo/lib/services/onesignal_api_service.dart @@ -72,12 +72,13 @@ class OneSignalApiService { return _postNotification(payload); } - // Retry on `invalid_player_ids` to absorb the brief race where the - // subscription has been created locally but is not yet visible to the - // /notifications endpoint. Future _postNotification(Map payload) async { - const maxAttempts = 3; + const maxAttempts = 5; + int backoffMs(int n) => 2000 * (1 << (n - 1)); + // Retry on `invalid_player_ids` to absorb the brief race where the + // subscription has been created locally but is not yet visible to the + // /notifications endpoint. for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { final response = await http.post( @@ -89,8 +90,8 @@ class OneSignalApiService { body: jsonEncode(payload), ); - debugPrint('Send notification response: ${response.statusCode}'); - if (response.statusCode != 200) { + if (response.statusCode < 200 || response.statusCode >= 300) { + debugPrint('Send notification failed: ${response.body}'); return false; } @@ -101,7 +102,9 @@ class OneSignalApiService { final invalidIds = errors['invalid_player_ids']; if (invalidIds is List && invalidIds.isNotEmpty) { if (attempt < maxAttempts) { - await Future.delayed(Duration(seconds: 3 * attempt)); + await Future.delayed( + Duration(milliseconds: backoffMs(attempt)), + ); continue; } debugPrint( diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index e9fdd12e..ee9b3aee 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -28,29 +28,6 @@ class PushSection extends StatelessWidget { padding: AppSpacing.cardPadding, child: Column( children: [ - Row( - children: [ - Text( - 'OneSignal ID', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(width: 12), - Expanded( - child: Semantics( - identifier: 'onesignal_id_value', - container: true, - child: SelectableText( - vm.oneSignalId ?? '—', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.end, - ), - ), - ), - ], - ), - const Divider(), Row( children: [ Text( diff --git a/examples/demo_pods/lib/services/onesignal_api_service.dart b/examples/demo_pods/lib/services/onesignal_api_service.dart index b4a7c8f4..5d98029c 100644 --- a/examples/demo_pods/lib/services/onesignal_api_service.dart +++ b/examples/demo_pods/lib/services/onesignal_api_service.dart @@ -72,12 +72,13 @@ class OneSignalApiService { return _postNotification(payload); } - // Retry on `invalid_player_ids` to absorb the brief race where the - // subscription has been created locally but is not yet visible to the - // /notifications endpoint. Future _postNotification(Map payload) async { - const maxAttempts = 3; + const maxAttempts = 5; + int backoffMs(int n) => 2000 * (1 << (n - 1)); + // Retry on `invalid_player_ids` to absorb the brief race where the + // subscription has been created locally but is not yet visible to the + // /notifications endpoint. for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { final response = await http.post( @@ -89,8 +90,8 @@ class OneSignalApiService { body: jsonEncode(payload), ); - debugPrint('Send notification response: ${response.statusCode}'); - if (response.statusCode != 200) { + if (response.statusCode < 200 || response.statusCode >= 300) { + debugPrint('Send notification failed: ${response.body}'); return false; } @@ -101,7 +102,9 @@ class OneSignalApiService { final invalidIds = errors['invalid_player_ids']; if (invalidIds is List && invalidIds.isNotEmpty) { if (attempt < maxAttempts) { - await Future.delayed(Duration(seconds: 3 * attempt)); + await Future.delayed( + Duration(milliseconds: backoffMs(attempt)), + ); continue; } debugPrint( diff --git a/examples/demo_pods/lib/widgets/sections/push_section.dart b/examples/demo_pods/lib/widgets/sections/push_section.dart index e9fdd12e..ee9b3aee 100644 --- a/examples/demo_pods/lib/widgets/sections/push_section.dart +++ b/examples/demo_pods/lib/widgets/sections/push_section.dart @@ -28,29 +28,6 @@ class PushSection extends StatelessWidget { padding: AppSpacing.cardPadding, child: Column( children: [ - Row( - children: [ - Text( - 'OneSignal ID', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(width: 12), - Expanded( - child: Semantics( - identifier: 'onesignal_id_value', - container: true, - child: SelectableText( - vm.oneSignalId ?? '—', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.end, - ), - ), - ), - ], - ), - const Divider(), Row( children: [ Text( From 08050edfb861323efab621542b609c17fdf87612 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 22 May 2026 22:24:21 -0700 Subject: [PATCH 11/11] fix(examples): broaden transient send failure detection. --- .../lib/services/onesignal_api_service.dart | 43 +++++++++++-------- .../demo/lib/viewmodels/app_viewmodel.dart | 6 ++- .../lib/services/onesignal_api_service.dart | 43 +++++++++++-------- .../lib/viewmodels/app_viewmodel.dart | 6 ++- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/examples/demo/lib/services/onesignal_api_service.dart b/examples/demo/lib/services/onesignal_api_service.dart index 5d98029c..9ac64eb3 100644 --- a/examples/demo/lib/services/onesignal_api_service.dart +++ b/examples/demo/lib/services/onesignal_api_service.dart @@ -76,9 +76,13 @@ class OneSignalApiService { const maxAttempts = 5; int backoffMs(int n) => 2000 * (1 << (n - 1)); - // Retry on `invalid_player_ids` to absorb the brief race where the - // subscription has been created locally but is not yet visible to the - // /notifications endpoint. + // Retry while the OneSignal backend hasn't yet indexed the freshly + // created subscription. The /notifications endpoint reports this race in a + // few different shapes, all of which return HTTP 200: + // - {"errors":{"invalid_player_ids":[...]}} + // - {"id":"","errors":["All included players are not subscribed"]} + // - {"id":"","errors":[...]} + // Treat any 200 response without a real notification id as transient. for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { final response = await http.post( @@ -96,23 +100,13 @@ class OneSignalApiService { } final decoded = jsonDecode(response.body); - if (decoded is Map) { - final errors = decoded['errors']; - if (errors is Map) { - final invalidIds = errors['invalid_player_ids']; - if (invalidIds is List && invalidIds.isNotEmpty) { - if (attempt < maxAttempts) { - await Future.delayed( - Duration(milliseconds: backoffMs(attempt)), - ); - continue; - } - debugPrint( - 'Send notification failed: invalid_player_ids $invalidIds', - ); - return false; - } + if (_isTransientSendFailure(decoded)) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(milliseconds: backoffMs(attempt))); + continue; } + debugPrint('Send notification failed: ${response.body}'); + return false; } return true; @@ -125,6 +119,17 @@ class OneSignalApiService { return false; } + bool _isTransientSendFailure(dynamic decoded) { + if (decoded is! Map) return false; + final id = decoded['id']; + final errors = decoded['errors']; + final hasErrors = + (errors is List && errors.isNotEmpty) || + (errors is Map && errors.isNotEmpty); + final missingId = id is! String || id.isEmpty; + return hasErrors || missingId; + } + Future updateLiveActivity( String activityId, Map eventUpdates, diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index d62c5e53..18f7a086 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -69,7 +69,11 @@ class AppViewModel extends ChangeNotifier { // Push state String? _pushSubscriptionId; - String? get pushSubscriptionId => _pushSubscriptionId; + // The native bridge can hand back an empty string before the subscription + // id is provisioned. Treat that as "no id yet" so the UI's `?? '—'` + // fallback renders the placeholder instead of an empty cell. + String? get pushSubscriptionId => + (_pushSubscriptionId?.isEmpty ?? true) ? null : _pushSubscriptionId; bool _pushEnabled = false; bool get pushEnabled => _pushEnabled; diff --git a/examples/demo_pods/lib/services/onesignal_api_service.dart b/examples/demo_pods/lib/services/onesignal_api_service.dart index 5d98029c..9ac64eb3 100644 --- a/examples/demo_pods/lib/services/onesignal_api_service.dart +++ b/examples/demo_pods/lib/services/onesignal_api_service.dart @@ -76,9 +76,13 @@ class OneSignalApiService { const maxAttempts = 5; int backoffMs(int n) => 2000 * (1 << (n - 1)); - // Retry on `invalid_player_ids` to absorb the brief race where the - // subscription has been created locally but is not yet visible to the - // /notifications endpoint. + // Retry while the OneSignal backend hasn't yet indexed the freshly + // created subscription. The /notifications endpoint reports this race in a + // few different shapes, all of which return HTTP 200: + // - {"errors":{"invalid_player_ids":[...]}} + // - {"id":"","errors":["All included players are not subscribed"]} + // - {"id":"","errors":[...]} + // Treat any 200 response without a real notification id as transient. for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { final response = await http.post( @@ -96,23 +100,13 @@ class OneSignalApiService { } final decoded = jsonDecode(response.body); - if (decoded is Map) { - final errors = decoded['errors']; - if (errors is Map) { - final invalidIds = errors['invalid_player_ids']; - if (invalidIds is List && invalidIds.isNotEmpty) { - if (attempt < maxAttempts) { - await Future.delayed( - Duration(milliseconds: backoffMs(attempt)), - ); - continue; - } - debugPrint( - 'Send notification failed: invalid_player_ids $invalidIds', - ); - return false; - } + if (_isTransientSendFailure(decoded)) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(milliseconds: backoffMs(attempt))); + continue; } + debugPrint('Send notification failed: ${response.body}'); + return false; } return true; @@ -125,6 +119,17 @@ class OneSignalApiService { return false; } + bool _isTransientSendFailure(dynamic decoded) { + if (decoded is! Map) return false; + final id = decoded['id']; + final errors = decoded['errors']; + final hasErrors = + (errors is List && errors.isNotEmpty) || + (errors is Map && errors.isNotEmpty); + final missingId = id is! String || id.isEmpty; + return hasErrors || missingId; + } + Future updateLiveActivity( String activityId, Map eventUpdates, diff --git a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart index 2ae61574..1d5e5715 100644 --- a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart @@ -64,7 +64,11 @@ class AppViewModel extends ChangeNotifier { // Push state String? _pushSubscriptionId; - String? get pushSubscriptionId => _pushSubscriptionId; + // The native bridge can hand back an empty string before the subscription + // id is provisioned. Treat that as "no id yet" so the UI's `?? '—'` + // fallback renders the placeholder instead of an empty cell. + String? get pushSubscriptionId => + (_pushSubscriptionId?.isEmpty ?? true) ? null : _pushSubscriptionId; bool _pushEnabled = false; bool get pushEnabled => _pushEnabled;