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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d1312eab..c335d267 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -110,7 +110,17 @@ 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 + 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/android/build.gradle b/android/build.gradle index 9efac32c..ac809f50 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 { @@ -38,5 +38,5 @@ android { } dependencies { - implementation 'com.onesignal:OneSignal:5.8.1' + implementation 'com.onesignal:OneSignal:5.9.2' } 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/examples/demo/lib/services/onesignal_api_service.dart b/examples/demo/lib/services/onesignal_api_service.dart index 94ab0c85..9ac64eb3 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,72 @@ 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; + Future _postNotification(Map payload) async { + const maxAttempts = 5; + int backoffMs(int n) => 2000 * (1 << (n - 1)); + + // 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( + Uri.parse('https://onesignal.com/api/v1/notifications'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.onesignal.v1+json', + }, + body: jsonEncode(payload), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + debugPrint('Send notification failed: ${response.body}'); + return false; + } + + final decoded = jsonDecode(response.body); + if (_isTransientSendFailure(decoded)) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(milliseconds: backoffMs(attempt))); + continue; + } + debugPrint('Send notification failed: ${response.body}'); + return false; + } + + return true; + } catch (e) { + debugPrint('Send notification error: $e'); + return false; + } } + + 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( diff --git a/examples/demo/lib/utils/mask_value.dart b/examples/demo/lib/utils/mask_value.dart deleted file mode 100644 index 0bd0c198..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) { - return _maskChar * value.length; - } - return value; -} diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index fb6a719c..18f7a086 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -64,9 +64,16 @@ class AppViewModel extends ChangeNotifier { bool get isLoggedIn => _externalUserId != null; + String? _oneSignalId; + String? get oneSignalId => _oneSignalId; + // 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; @@ -139,9 +146,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 +180,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/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 83e810aa..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 ?? 'N/A'), + 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/services/onesignal_api_service.dart b/examples/demo_pods/lib/services/onesignal_api_service.dart index 94ab0c85..9ac64eb3 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,72 @@ 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; + Future _postNotification(Map payload) async { + const maxAttempts = 5; + int backoffMs(int n) => 2000 * (1 << (n - 1)); + + // 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( + Uri.parse('https://onesignal.com/api/v1/notifications'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.onesignal.v1+json', + }, + body: jsonEncode(payload), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + debugPrint('Send notification failed: ${response.body}'); + return false; + } + + final decoded = jsonDecode(response.body); + if (_isTransientSendFailure(decoded)) { + if (attempt < maxAttempts) { + await Future.delayed(Duration(milliseconds: backoffMs(attempt))); + continue; + } + debugPrint('Send notification failed: ${response.body}'); + return false; + } + + return true; + } catch (e) { + debugPrint('Send notification error: $e'); + return false; + } } + + 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( 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 0bd0c198..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) { - return _maskChar * value.length; - } - return value; -} diff --git a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart index ba7edb2c..1d5e5715 100644 --- a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart @@ -59,9 +59,16 @@ class AppViewModel extends ChangeNotifier { bool get isLoggedIn => _externalUserId != null; + String? _oneSignalId; + String? get oneSignalId => _oneSignalId; + // 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; @@ -134,9 +141,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 +181,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/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 83e810aa..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 ?? 'N/A'), + 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', ), 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