diff --git a/android/build.gradle b/android/build.gradle index 2527b38d..4e8aec47 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ def safeEnvFlagGet(prop) { return normalizedValue != null && (normalizedValue.equalsIgnoreCase('true') || normalizedValue == '1') } -def oneSignalVersion = '5.10.2' +def oneSignalVersion = '5.11.0' def oneSignalDisableLocation = safeEnvFlagGet('ONESIGNAL_DISABLE_LOCATION') buildscript { diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java index 4e8aab75..be462d0c 100644 --- a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java +++ b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java @@ -3,6 +3,8 @@ import android.content.Context; import androidx.annotation.NonNull; import com.onesignal.OneSignal; +import com.onesignal.OneSignalResult; +import com.onesignal.OneSignalUserProfile; import com.onesignal.common.OneSignalWrapper; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; @@ -12,6 +14,13 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import kotlin.coroutines.Continuation; +import kotlin.coroutines.CoroutineContext; +import kotlin.coroutines.EmptyCoroutineContext; +import kotlin.coroutines.intrinsics.IntrinsicsKt; /** OnesignalPlugin */ public class OneSignalPlugin extends FlutterMessengerResponder @@ -101,6 +110,7 @@ private void handleMethodCall(MethodCall call, Result result) { else if (call.method.contentEquals("OneSignal#consentGiven")) this.setConsentGiven(call, result); else if (call.method.contentEquals("OneSignal#login")) this.login(call, result); else if (call.method.contentEquals("OneSignal#loginWithJWT")) this.loginWithJWT(call, result); + else if (call.method.contentEquals("OneSignal#loginWithProfile")) this.loginWithProfile(call, result); else if (call.method.contentEquals("OneSignal#logout")) this.logout(call, result); else replyNotImplemented(result); } @@ -133,6 +143,73 @@ private void loginWithJWT(MethodCall call, Result result) { replySuccess(result, null); } + private void loginWithProfile(MethodCall call, final Result result) { + Map rawProfile = call.argument("profile"); + OneSignalUserProfile profile = new OneSignalUserProfile.Builder() + .setEmail(stringOrNull(rawProfile, "email")) + .setSms(stringOrNull(rawProfile, "sms")) + .setTags(stringMap(rawProfile, "tags")) + .setAliases(stringMap(rawProfile, "aliases")) + .build(); + + final AtomicBoolean replied = new AtomicBoolean(false); + Continuation continuation = new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object outcome) { + replyLoginResult(result, replied, outcome); + } + }; + + Object returned = OneSignal.login( + (String) call.argument("externalId"), profile, (String) call.argument("jwtBearerToken"), continuation); + // A suspend function that completes without suspending returns its result here + // and never calls resumeWith. + if (returned != IntrinsicsKt.getCOROUTINE_SUSPENDED()) { + replyLoginResult(result, replied, returned); + } + } + + private void replyLoginResult(Result result, AtomicBoolean replied, Object outcome) { + if (!replied.compareAndSet(false, true)) return; + String message = "Unexpected loginWithProfile result"; + if (outcome instanceof OneSignalResult) { + // May run on the SDK's coroutine thread, so a conversion failure must not escape. + try { + replySuccess(result, OneSignalSerializer.convertToCodecValue(((OneSignalResult) outcome).toMap())); + return; + } catch (Throwable t) { + message = String.valueOf(t); + } + } else if (outcome instanceof kotlin.Result.Failure) { + Throwable exception = ((kotlin.Result.Failure) outcome).exception; + message = exception.getMessage() != null ? exception.getMessage() : exception.toString(); + } + replyError(result, "OneSignal", message, null); + } + + private static String stringOrNull(Map map, String key) { + Object value = map == null ? null : map.get(key); + return value instanceof String ? (String) value : null; + } + + private static Map stringMap(Map map, String key) { + Map converted = new HashMap<>(); + Object value = map == null ? null : map.get(key); + if (!(value instanceof Map)) return converted; + for (Map.Entry entry : ((Map) value).entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + converted.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + return converted; + } + private void logout(MethodCall call, Result result) { OneSignal.logout(); replySuccess(result, null); diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalSerializer.java b/android/src/main/java/com/onesignal/flutter/OneSignalSerializer.java index 65dba35f..5207906f 100644 --- a/android/src/main/java/com/onesignal/flutter/OneSignalSerializer.java +++ b/android/src/main/java/com/onesignal/flutter/OneSignalSerializer.java @@ -17,9 +17,11 @@ import com.onesignal.user.subscriptions.PushSubscriptionChangedState; import com.onesignal.user.subscriptions.PushSubscriptionState; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -255,6 +257,33 @@ private static List convertJSONArrayToList(JSONArray array) throws JSONE return list; } + /** Reduces a value to types StandardMessageCodec can encode, since an unsupported type throws on the main thread. */ + static Object convertToCodecValue(Object value) { + if (value == null || value == JSONObject.NULL) return null; + if (value instanceof String || value instanceof Boolean) return value; + if (value instanceof Integer || value instanceof Long || value instanceof Double) return value; + if (value instanceof Number) return ((Number) value).doubleValue(); + if (value instanceof Map) { + HashMap map = new HashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + map.put(String.valueOf(entry.getKey()), convertToCodecValue(entry.getValue())); + } + return map; + } + if (value instanceof Collection) { + List list = new ArrayList<>(); + for (Object item : (Collection) value) list.add(convertToCodecValue(item)); + return list; + } + try { + if (value instanceof JSONObject) return convertJSONObjectToHashMap((JSONObject) value); + if (value instanceof JSONArray) return convertJSONArrayToList((JSONArray) value); + } catch (JSONException e) { + return value.toString(); + } + return value.toString(); + } + /** Helper method to return null value if string is empty **/ static String setNullIfEmpty(String value) { return value.isEmpty() ? null : value; diff --git a/examples/demo-fm/lib/viewmodels/app_viewmodel.dart b/examples/demo-fm/lib/viewmodels/app_viewmodel.dart index 2a8d5b43..bb7c4956 100644 --- a/examples/demo-fm/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo-fm/lib/viewmodels/app_viewmodel.dart @@ -260,6 +260,40 @@ class AppViewModel extends ChangeNotifier { } } + Future loginWithProfile( + String externalUserId, { + String? email, + String? sms, + String? jwt, + }) async { + _aliasesList = []; + _emailsList = []; + _smsNumbersList = []; + _tagsList = []; + _triggersList = []; + _isLoading = true; + notifyListeners(); + + final result = await OneSignal.loginWithProfile( + externalUserId, + OSUserProfile(email: email, sms: sms), + jwt, + ); + if (!result.success) { + final error = result.error?.first; + debugPrint('Login with profile error: ${error?.code} ${error?.message}'); + _isLoading = false; + notifyListeners(); + return; + } + + _externalUserId = externalUserId; + await _prefs.setExternalUserId(externalUserId); + debugPrint('Logged in with profile as: $externalUserId'); + // The user observer does not fire when the external id is unchanged. + await fetchUserDataFromApi(); + } + Future logoutUser() async { _externalUserId = null; _aliasesList = []; diff --git a/examples/demo-fm/lib/widgets/dialogs.dart b/examples/demo-fm/lib/widgets/dialogs.dart index 10ad3233..bff5d158 100644 --- a/examples/demo-fm/lib/widgets/dialogs.dart +++ b/examples/demo-fm/lib/widgets/dialogs.dart @@ -75,6 +75,125 @@ class _SingleInputDialogState extends State { } } +typedef LoginWithProfileInput = ({ + String externalId, + String? email, + String? sms, + String? jwt, +}); + +// Login with profile dialog (external id plus optional email, sms, jwt) +class LoginWithProfileDialog extends StatefulWidget { + const LoginWithProfileDialog({super.key}); + + @override + State createState() => _LoginWithProfileDialogState(); +} + +class _LoginWithProfileDialogState extends State { + final _externalIdController = TextEditingController(); + final _emailController = TextEditingController(); + final _smsController = TextEditingController(); + final _jwtController = TextEditingController(); + + @override + void dispose() { + _externalIdController.dispose(); + _emailController.dispose(); + _smsController.dispose(); + _jwtController.dispose(); + super.dispose(); + } + + String? _optional(TextEditingController controller) { + final text = controller.text.trim(); + return text.isEmpty ? null : text; + } + + Widget _field( + String identifier, + String label, + TextEditingController controller, { + TextInputType keyboardType = TextInputType.text, + }) { + return Semantics( + identifier: identifier, + container: true, + child: AppTextField( + controller: controller, + decoration: InputDecoration(labelText: label), + keyboardType: keyboardType, + onChanged: (_) => setState(() {}), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('Login With Profile'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _field( + 'composite_login_user_id_input', + 'External User Id', + _externalIdController, + ), + AppSpacing.gapBox, + _field( + 'composite_login_email_input', + 'Email (optional)', + _emailController, + keyboardType: TextInputType.emailAddress, + ), + AppSpacing.gapBox, + _field( + 'composite_login_sms_input', + 'SMS (optional)', + _smsController, + keyboardType: TextInputType.phone, + ), + AppSpacing.gapBox, + _field( + 'composite_login_jwt_input', + 'JWT Token (optional)', + _jwtController, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + Semantics( + identifier: 'composite_login_confirm_button', + container: true, + button: true, + child: TextButton( + onPressed: _externalIdController.text.trim().isEmpty + ? null + : () => Navigator.pop(context, ( + externalId: _externalIdController.text.trim(), + email: _optional(_emailController), + sms: _optional(_smsController), + jwt: _optional(_jwtController), + )), + child: const Text('Login'), + ), + ), + ], + ); + } +} + // Key-value pair input dialog (single pair) class PairInputDialog extends StatefulWidget { final String title; diff --git a/examples/demo-fm/lib/widgets/sections/user_section.dart b/examples/demo-fm/lib/widgets/sections/user_section.dart index efd53193..b9a57c16 100644 --- a/examples/demo-fm/lib/widgets/sections/user_section.dart +++ b/examples/demo-fm/lib/widgets/sections/user_section.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -91,6 +92,27 @@ class UserSection extends StatelessWidget { } }, ), + if (defaultTargetPlatform == TargetPlatform.android) ...[ + AppSpacing.gapBox, + DestructiveButton( + label: 'LOGIN WITH PROFILE', + semanticsLabel: 'composite_login_button', + onPressed: () async { + final input = await showDialog( + context: context, + builder: (_) => const LoginWithProfileDialog(), + ); + if (input != null && context.mounted) { + await vm.loginWithProfile( + input.externalId, + email: input.email, + sms: input.sms, + jwt: input.jwt, + ); + } + }, + ), + ], if (vm.isLoggedIn) ...[ AppSpacing.gapBox, DestructiveButton( diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index e2d69e96..41118fbc 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -261,6 +261,40 @@ class AppViewModel extends ChangeNotifier { } } + Future loginWithProfile( + String externalUserId, { + String? email, + String? sms, + String? jwt, + }) async { + _aliasesList = []; + _emailsList = []; + _smsNumbersList = []; + _tagsList = []; + _triggersList = []; + _isLoading = true; + notifyListeners(); + + final result = await OneSignal.loginWithProfile( + externalUserId, + OSUserProfile(email: email, sms: sms), + jwt, + ); + if (!result.success) { + final error = result.error?.first; + debugPrint('Login with profile error: ${error?.code} ${error?.message}'); + _isLoading = false; + notifyListeners(); + return; + } + + _externalUserId = externalUserId; + await _prefs.setExternalUserId(externalUserId); + debugPrint('Logged in with profile as: $externalUserId'); + // The user observer does not fire when the external id is unchanged. + await fetchUserDataFromApi(); + } + Future logoutUser() async { _externalUserId = null; _aliasesList = []; diff --git a/examples/demo/lib/widgets/dialogs.dart b/examples/demo/lib/widgets/dialogs.dart index 10ad3233..bff5d158 100644 --- a/examples/demo/lib/widgets/dialogs.dart +++ b/examples/demo/lib/widgets/dialogs.dart @@ -75,6 +75,125 @@ class _SingleInputDialogState extends State { } } +typedef LoginWithProfileInput = ({ + String externalId, + String? email, + String? sms, + String? jwt, +}); + +// Login with profile dialog (external id plus optional email, sms, jwt) +class LoginWithProfileDialog extends StatefulWidget { + const LoginWithProfileDialog({super.key}); + + @override + State createState() => _LoginWithProfileDialogState(); +} + +class _LoginWithProfileDialogState extends State { + final _externalIdController = TextEditingController(); + final _emailController = TextEditingController(); + final _smsController = TextEditingController(); + final _jwtController = TextEditingController(); + + @override + void dispose() { + _externalIdController.dispose(); + _emailController.dispose(); + _smsController.dispose(); + _jwtController.dispose(); + super.dispose(); + } + + String? _optional(TextEditingController controller) { + final text = controller.text.trim(); + return text.isEmpty ? null : text; + } + + Widget _field( + String identifier, + String label, + TextEditingController controller, { + TextInputType keyboardType = TextInputType.text, + }) { + return Semantics( + identifier: identifier, + container: true, + child: AppTextField( + controller: controller, + decoration: InputDecoration(labelText: label), + keyboardType: keyboardType, + onChanged: (_) => setState(() {}), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('Login With Profile'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _field( + 'composite_login_user_id_input', + 'External User Id', + _externalIdController, + ), + AppSpacing.gapBox, + _field( + 'composite_login_email_input', + 'Email (optional)', + _emailController, + keyboardType: TextInputType.emailAddress, + ), + AppSpacing.gapBox, + _field( + 'composite_login_sms_input', + 'SMS (optional)', + _smsController, + keyboardType: TextInputType.phone, + ), + AppSpacing.gapBox, + _field( + 'composite_login_jwt_input', + 'JWT Token (optional)', + _jwtController, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + Semantics( + identifier: 'composite_login_confirm_button', + container: true, + button: true, + child: TextButton( + onPressed: _externalIdController.text.trim().isEmpty + ? null + : () => Navigator.pop(context, ( + externalId: _externalIdController.text.trim(), + email: _optional(_emailController), + sms: _optional(_smsController), + jwt: _optional(_jwtController), + )), + child: const Text('Login'), + ), + ), + ], + ); + } +} + // Key-value pair input dialog (single pair) class PairInputDialog extends StatefulWidget { final String title; diff --git a/examples/demo/lib/widgets/sections/user_section.dart b/examples/demo/lib/widgets/sections/user_section.dart index efd53193..b9a57c16 100644 --- a/examples/demo/lib/widgets/sections/user_section.dart +++ b/examples/demo/lib/widgets/sections/user_section.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -91,6 +92,27 @@ class UserSection extends StatelessWidget { } }, ), + if (defaultTargetPlatform == TargetPlatform.android) ...[ + AppSpacing.gapBox, + DestructiveButton( + label: 'LOGIN WITH PROFILE', + semanticsLabel: 'composite_login_button', + onPressed: () async { + final input = await showDialog( + context: context, + builder: (_) => const LoginWithProfileDialog(), + ); + if (input != null && context.mounted) { + await vm.loginWithProfile( + input.externalId, + email: input.email, + sms: input.sms, + jwt: input.jwt, + ); + } + }, + ), + ], if (vm.isLoggedIn) ...[ AppSpacing.gapBox, DestructiveButton( diff --git a/lib/onesignal_flutter.dart b/lib/onesignal_flutter.dart index 974140db..c934e92a 100644 --- a/lib/onesignal_flutter.dart +++ b/lib/onesignal_flutter.dart @@ -6,6 +6,7 @@ import 'package:onesignal_flutter/src/debug.dart'; import 'package:onesignal_flutter/src/inappmessages.dart'; import 'package:onesignal_flutter/src/liveactivities.dart'; import 'package:onesignal_flutter/src/location.dart'; +import 'package:onesignal_flutter/src/loginresult.dart'; import 'package:onesignal_flutter/src/notifications.dart'; import 'package:onesignal_flutter/src/session.dart'; import 'package:onesignal_flutter/src/user.dart'; @@ -15,6 +16,7 @@ export 'src/defines.dart'; export 'src/inappmessage.dart'; export 'src/inappmessages.dart'; export 'src/liveactivities.dart'; +export 'src/loginresult.dart'; export 'src/notification.dart'; export 'src/notifications.dart'; export 'src/pushsubscription.dart'; @@ -69,6 +71,50 @@ class OneSignal { .invokeMethod('OneSignal#login', {'externalId': externalId}); } + /// Logs in as [externalId] and applies [profile] in the same request, resolving with the outcome. + /// + /// Android only for now: on iOS this returns a failure without calling the native SDK. + static Future loginWithProfile( + String externalId, OSUserProfile profile, + [String? jwtBearerToken]) async { + if (defaultTargetPlatform != TargetPlatform.android) { + return OSLoginResult.failure( + 'UNKNOWN', 'loginWithProfile is only supported on Android'); + } + final invalid = _validateProfileLogin(externalId, profile); + if (invalid != null) { + logError(invalid); + return OSLoginResult.failure('INVALID_ARGUMENT', invalid); + } + try { + final raw = await _channel.invokeMethod('OneSignal#loginWithProfile', { + 'externalId': externalId, + 'profile': profile.mapRepresentation(), + 'jwtBearerToken': jwtBearerToken, + }); + return OSLoginResult.fromWire(raw); + } catch (e) { + final message = + e is PlatformException ? (e.message ?? e.code) : e.toString(); + logError('loginWithProfile failed: $message'); + return OSLoginResult.failure('UNKNOWN', message); + } + } + + static String? _validateProfileLogin( + String externalId, OSUserProfile profile) { + if (externalId.isEmpty) return 'loginWithProfile: externalId is required'; + for (final entry in (profile.tags ?? const {}).entries) { + if (entry.key.isEmpty) return 'loginWithProfile: tag key is required'; + } + for (final entry in (profile.aliases ?? const {}).entries) { + if (entry.key.isEmpty || entry.value.isEmpty) { + return 'loginWithProfile: alias label and id are required'; + } + } + return null; + } + /// Login to OneSignal under the user identified by the [externalId] provided. /// /// The act of logging a user into the OneSignal SDK will switch the diff --git a/lib/src/loginresult.dart b/lib/src/loginresult.dart new file mode 100644 index 00000000..316ca732 --- /dev/null +++ b/lib/src/loginresult.dart @@ -0,0 +1,135 @@ +/// Profile applied to the user as part of [OneSignal.loginWithProfile]. +class OSUserProfile { + final String? email; + final String? sms; + final Map? tags; + final Map? aliases; + + const OSUserProfile({this.email, this.sms, this.tags, this.aliases}); + + Map mapRepresentation() => { + 'email': email, + 'sms': sms, + 'tags': tags, + 'aliases': aliases, + }; +} + +/// The payload of a successful [OneSignal.loginWithProfile]. +class OSLoginData { + final String onesignalId; + final String externalId; + final String? emailSubscriptionId; + final String? smsSubscriptionId; + + const OSLoginData({ + required this.onesignalId, + required this.externalId, + this.emailSubscriptionId, + this.smsSubscriptionId, + }); +} + +/// A single reason a call failed. +class OSResultError { + /// One of BACKEND_ERROR, INVALID_ARGUMENT, NOT_INITIALIZED, STORAGE_LOCKED, UNKNOWN. + final String code; + + /// CLIENT or BACKEND. + final String source; + final String? backendCode; + final int? httpStatus; + final String message; + final Map? meta; + + const OSResultError({ + required this.code, + required this.source, + required this.message, + this.backendCode, + this.httpStatus, + this.meta, + }); +} + +/// The outcome of [OneSignal.loginWithProfile]: [data] on success, a non-empty [error] otherwise. +class OSLoginResult { + final bool success; + final OSLoginData? data; + final List? error; + + const OSLoginResult._(this.success, this.data, this.error); + + factory OSLoginResult.failure(String code, String message) => OSLoginResult._( + false, + null, + [OSResultError(code: code, source: 'CLIENT', message: message)], + ); + + /// Never throws: anything that is not a well-formed envelope becomes an UNKNOWN failure. + factory OSLoginResult.fromWire(dynamic raw) { + if (raw is! Map) return _malformed(); + final success = raw['success']; + if (success == true) { + final error = raw['error']; + if (error is List && error.isNotEmpty) return _malformed(); + final data = _parseData(raw['data']); + return data == null ? _malformed() : OSLoginResult._(true, data, null); + } + if (success != false) return _malformed(); + final error = raw['error']; + if (error is! List || error.isEmpty) return _malformed(); + return OSLoginResult._(false, null, error.map(_parseError).toList()); + } + + static const _codes = { + 'BACKEND_ERROR', + 'INVALID_ARGUMENT', + 'NOT_INITIALIZED', + 'STORAGE_LOCKED', + 'UNKNOWN', + }; + + static OSLoginResult _malformed() => + OSLoginResult.failure('UNKNOWN', 'Malformed loginWithProfile result'); + + static OSLoginData? _parseData(dynamic raw) { + if (raw is! Map) return null; + final onesignalId = _string(raw['onesignalId']); + final externalId = _string(raw['externalId']); + if (onesignalId == null || externalId == null) return null; + return OSLoginData( + onesignalId: onesignalId, + externalId: externalId, + emailSubscriptionId: _string(raw['emailSubscriptionId']), + smsSubscriptionId: _string(raw['smsSubscriptionId']), + ); + } + + static OSResultError _parseError(dynamic raw) { + if (raw is! Map) { + return const OSResultError( + code: 'UNKNOWN', + source: 'CLIENT', + message: 'Malformed error', + ); + } + final code = raw['code']; + final source = raw['source']; + final httpStatus = raw['httpStatus']; + final meta = raw['meta']; + return OSResultError( + code: _codes.contains(code) ? code as String : 'UNKNOWN', + source: source == 'BACKEND' ? 'BACKEND' : 'CLIENT', + backendCode: _string(raw['backendCode']), + httpStatus: httpStatus is int ? httpStatus : null, + message: raw['message'] is String ? raw['message'] as String : '', + meta: meta is Map + ? meta.map((key, value) => MapEntry(key.toString(), value)) + : null, + ); + } + + static String? _string(dynamic value) => + value is String && value.isNotEmpty ? value : null; +} diff --git a/test/mock_channel.dart b/test/mock_channel.dart index 20d3a8b2..ada77293 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -152,6 +152,11 @@ class OneSignalMockChannelController { state.externalId = (call.arguments as Map)['externalId'] as String?; break; + case "OneSignal#loginWithProfile": + state.loginWithProfileArgs = call.arguments as Map?; + final response = state.loginWithProfileResponse; + if (response is Exception) throw response; + return response; case "OneSignal#logout": state.externalId = null; break; @@ -417,6 +422,8 @@ class OneSignalState { bool? disablePush; String? externalId; String? language; + Map? loginWithProfileArgs; + dynamic loginWithProfileResponse; // live activities bool? liveActivityEntered; diff --git a/test/onesignalflutter_test.dart b/test/onesignalflutter_test.dart index 56a82d9d..2a19fede 100644 --- a/test/onesignalflutter_test.dart +++ b/test/onesignalflutter_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:onesignal_flutter/onesignal_flutter.dart'; @@ -37,6 +38,206 @@ void main() { }); }); + group('loginWithProfile', () { + const profile = OSUserProfile( + email: 'a@example.com', + sms: '+14155552671', + tags: {'tier': 'gold'}, + aliases: {'crm': 'c-1'}, + ); + + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + tearDown(() => debugDefaultTargetPlatformOverride = null); + + test('marshals externalId, profile and jwt', () async { + await OneSignal.loginWithProfile('user-123', profile, 'jwt-1'); + + final args = channelController.state.loginWithProfileArgs!; + expect(args['externalId'], equals('user-123')); + expect(args['jwtBearerToken'], equals('jwt-1')); + expect( + args['profile'], + equals({ + 'email': 'a@example.com', + 'sms': '+14155552671', + 'tags': {'tier': 'gold'}, + 'aliases': {'crm': 'c-1'}, + }), + ); + }); + + test('omitted jwt is sent as null', () async { + await OneSignal.loginWithProfile('user-123', const OSUserProfile()); + + final args = channelController.state.loginWithProfileArgs!; + expect(args.containsKey('jwtBearerToken'), isTrue); + expect(args['jwtBearerToken'], isNull); + }); + + test('parses a success envelope', () async { + channelController.state.loginWithProfileResponse = { + 'success': true, + 'data': { + 'onesignalId': 'os-1', + 'externalId': 'user-123', + 'emailSubscriptionId': 'email-sub', + }, + 'error': null, + }; + + final result = await OneSignal.loginWithProfile('user-123', profile); + + expect(result.success, isTrue); + expect(result.error, isNull); + expect(result.data!.onesignalId, equals('os-1')); + expect(result.data!.externalId, equals('user-123')); + expect(result.data!.emailSubscriptionId, equals('email-sub')); + expect(result.data!.smsSubscriptionId, isNull); + }); + + test('parses a failure envelope', () async { + channelController.state.loginWithProfileResponse = { + 'success': false, + 'data': null, + 'error': [ + { + 'code': 'BACKEND_ERROR', + 'source': 'BACKEND', + 'backendCode': 'user-1', + 'httpStatus': 409, + 'message': 'Conflict', + 'meta': {'conflicting_aliases': 'crm'}, + }, + {'code': 'SOMETHING_NEW', 'source': 'CLIENT', 'message': 'x'}, + ], + }; + + final result = await OneSignal.loginWithProfile('user-123', profile); + + expect(result.success, isFalse); + expect(result.data, isNull); + final first = result.error!.first; + expect(first.code, equals('BACKEND_ERROR')); + expect(first.source, equals('BACKEND')); + expect(first.backendCode, equals('user-1')); + expect(first.httpStatus, equals(409)); + expect(first.message, equals('Conflict')); + expect(first.meta, equals({'conflicting_aliases': 'crm'})); + expect(result.error![1].code, equals('UNKNOWN')); + }); + + for (final malformed in [ + null, + 'nope', + {'success': 'yes'}, + {'success': true, 'data': null}, + { + 'success': true, + 'data': {'onesignalId': 'os-1'}, + }, + {'success': false, 'error': []}, + {'success': false, 'error': 'boom'}, + { + 'success': true, + 'data': {'onesignalId': 'os-1', 'externalId': 'user-123'}, + 'error': [ + {'code': 'BACKEND_ERROR', 'source': 'BACKEND', 'message': 'x'}, + ], + }, + ]) { + test( + 'treats malformed envelope $malformed as UNKNOWN failure', + () async { + channelController.state.loginWithProfileResponse = malformed; + + final result = await OneSignal.loginWithProfile( + 'user-123', + profile, + ); + + expect(result.success, isFalse); + expect(result.data, isNull); + expect(result.error!.single.code, equals('UNKNOWN')); + }, + ); + } + + test('a non-map error entry becomes UNKNOWN', () async { + channelController.state.loginWithProfileResponse = { + 'success': false, + 'error': ['boom'], + }; + + final result = await OneSignal.loginWithProfile('user-123', profile); + + expect(result.error!.single.code, equals('UNKNOWN')); + expect(result.error!.single.source, equals('CLIENT')); + }); + + test('a platform exception becomes an UNKNOWN failure', () async { + channelController.state.loginWithProfileResponse = PlatformException( + code: 'OneSignal', + message: 'native threw', + ); + + final result = await OneSignal.loginWithProfile('user-123', profile); + + expect(result.success, isFalse); + expect(result.error!.single.code, equals('UNKNOWN')); + expect(result.error!.single.message, equals('native threw')); + }); + + test('rejects empty externalId without calling native', () async { + final result = await OneSignal.loginWithProfile('', profile); + + expect(channelController.state.loginWithProfileArgs, isNull); + expect(result.success, isFalse); + expect(result.error!.single.code, equals('INVALID_ARGUMENT')); + expect(result.error!.single.source, equals('CLIENT')); + }); + + test( + 'rejects an empty tag key or alias id without calling native', + () async { + final badTag = await OneSignal.loginWithProfile( + 'user-123', + const OSUserProfile(tags: {'': 'v'}), + ); + final badAlias = await OneSignal.loginWithProfile( + 'user-123', + const OSUserProfile(aliases: {'crm': ''}), + ); + + expect(channelController.state.loginWithProfileArgs, isNull); + expect(badTag.error!.single.code, equals('INVALID_ARGUMENT')); + expect(badAlias.error!.single.code, equals('INVALID_ARGUMENT')); + }, + ); + + test( + 'returns the failure envelope on iOS without calling native', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + final result = await OneSignal.loginWithProfile('user-123', profile); + + expect(channelController.state.loginWithProfileArgs, isNull); + expect(result.success, isFalse); + expect(result.data, isNull); + final error = result.error!.single; + expect(error.code, equals('UNKNOWN')); + expect(error.source, equals('CLIENT')); + expect(error.backendCode, isNull); + expect(error.httpStatus, isNull); + expect(error.meta, isNull); + expect( + error.message, + equals('loginWithProfile is only supported on Android'), + ); + }, + ); + }); + group('loginWithJWT', () { test('loginWithJWT invokes native method on Android only', () async { // Override platform to Android for this test