From d72043a9c591a1b9d22ebb247ccaf52c7c567d5c Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 13 Oct 2026 10:40:55 -0500 Subject: [PATCH 1/3] feat: [SDK-5024] add loginWithProfile (Android only) Adds OneSignal.LoginWithProfileAsync(externalId, profile, jwt) returning a typed LoginResult. Android calls the native suspend login through Continue.with and parses OneSignalResult.toMap(). iOS and the Editor return the unsupported failure without touching native code. Bumps the native Android SDK to 5.11.0 and adds LOGIN WITH PROFILE to the demo. Co-authored-by: Cursor --- .../Editor/OneSignalAndroidDependencies.cs | 2 +- .../Runtime/OneSignalAndroid.cs | 52 +++++ .../Runtime/Utilities/AndroidJavaProxy.cs | 62 ++++++ .../Editor/Platform/OneSignalNative.cs | 6 + com.onesignal.unity.core/Runtime/OneSignal.cs | 14 ++ .../Runtime/OneSignalPlatform.cs | 11 + .../Runtime/User/Models/LoginResult.cs | 197 ++++++++++++++++++ .../Runtime/User/Models/LoginResult.cs.meta | 11 + .../User/Models/OneSignalUserProfile.cs | 15 ++ .../User/Models/OneSignalUserProfile.cs.meta | 11 + .../Runtime/OneSignaliOS.cs | 6 + examples/build.md | 1 + .../Editor/OneSignalAndroidDependencies.xml | 2 +- .../Plugins/Android/mainTemplate.gradle | 2 +- .../UI/Dialogs/LoginWithProfileDialog.cs | 74 +++++++ .../UI/Dialogs/LoginWithProfileDialog.cs.meta | 2 + .../UI/Sections/UserSectionController.cs | 19 ++ .../Assets/Scripts/ViewModels/AppViewModel.cs | 52 +++++ .../AndroidResolverDependencies.xml | 2 +- 19 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs create mode 100644 com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs.meta create mode 100644 com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs create mode 100644 com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs.meta create mode 100644 examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs create mode 100644 examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs.meta diff --git a/com.onesignal.unity.android/Editor/OneSignalAndroidDependencies.cs b/com.onesignal.unity.android/Editor/OneSignalAndroidDependencies.cs index d67c7249b..7ae11429a 100644 --- a/com.onesignal.unity.android/Editor/OneSignalAndroidDependencies.cs +++ b/com.onesignal.unity.android/Editor/OneSignalAndroidDependencies.cs @@ -70,7 +70,7 @@ internal static void WriteDependencies() } } - internal const string Version = "5.10.2"; + internal const string Version = "5.11.0"; private static readonly string _dependenciesPath = Path.Combine( "Assets", diff --git a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs index c8b7bc8f4..a707c4b0a 100644 --- a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs +++ b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs @@ -25,8 +25,10 @@ * THE SOFTWARE. */ +using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using OneSignalSDK.Android.Debug; using OneSignalSDK.Android.InAppMessages; using OneSignalSDK.Android.LiveActivities; @@ -34,6 +36,7 @@ using OneSignalSDK.Android.Notifications; using OneSignalSDK.Android.Session; using OneSignalSDK.Android.User; +using OneSignalSDK.Android.Utilities; using OneSignalSDK.Debug; using OneSignalSDK.Debug.Utilities; using OneSignalSDK.InAppMessages; @@ -206,6 +209,55 @@ public override void Login(string externalId, string jwtBearerToken = null) _sdkClass.CallStatic("login", externalId, jwtBearerToken); } + public override Task LoginWithProfileAsync( + string externalId, + OneSignalUserProfile profile, + string jwtBearerToken = null + ) + { + var invalid = LoginResult.ValidateInputs(externalId, profile); + if (invalid != null) + return Task.FromResult(invalid); + + var continuation = new LoginResultContinuation(); + try + { + // Resolved before the call so nothing after it can fail and mask a native result. + using var resultClass = new AndroidJavaClass(SDKPackage + ".OneSignalResult"); + using var tags = (profile.Tags ?? new Dictionary()).ToMap(); + using var aliases = (profile.Aliases ?? new Dictionary()).ToMap(); + using var nativeProfile = new AndroidJavaObject( + SDKPackage + ".OneSignalUserProfile", + profile.Email, + profile.Sms, + tags, + aliases + ); + using var proxy = continuation.CreateProxy(); + using var returned = _sdkClass.CallStatic( + "login", + externalId, + nativeProfile, + jwtBearerToken, + proxy + ); + + // A suspend call returns either COROUTINE_SUSPENDED or its result. Testing for the + // result type survives R8 renaming of the kotlin stdlib marker class. + if ( + returned != null + && AndroidJNI.IsInstanceOf(returned.GetRawObject(), resultClass.GetRawClass()) + ) + continuation.Resolve(returned); + } + catch (Exception e) + { + continuation.Fail(e.Message); + } + + return continuation.Result; + } + public override void Logout() { _sdkClass.CallStatic("logout"); diff --git a/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs b/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs index 6fdb094f0..43745d9e2 100644 --- a/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs +++ b/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using Laters; +using OneSignalSDK.User; using UnityEngine; namespace OneSignalSDK.Android.Utilities @@ -66,6 +67,67 @@ public void accept(AndroidJavaObject obj) } } + /// + /// Completes with the native OneSignalResult, whether it arrives through the continuation or + /// is returned synchronously by the suspend call. Completes exactly once and never throws. + /// + internal sealed class LoginResultContinuation : AndroidJavaProxy + { + private readonly TaskCompletionSource _completion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public Task Result => _completion.Task; + + public LoginResultContinuation() + : base("java.util.function.Consumer") { } + + public AndroidJavaObject CreateProxy() + { + using var continuation = new AndroidJavaClass("com.onesignal.Continue"); + return continuation.CallStatic("with", this); + } + + public void accept(AndroidJavaObject continueResult) + { + try + { + if (continueResult.Call("isSuccess")) + { + using var result = continueResult.Call("getData"); + Resolve(result); + } + else + { + using var throwable = continueResult.Call("getThrowable"); + Fail(throwable?.Call("toString") ?? "loginWithProfile failed"); + } + } + catch (Exception e) + { + Fail(e.Message); + } + } + + public void Resolve(AndroidJavaObject oneSignalResult) + { + try + { + using var map = oneSignalResult.Call("toMap"); + using var json = new AndroidJavaObject("org.json.JSONObject", map); + _completion.TrySetResult(LoginResult.FromJson(json.Call("toString"))); + } + catch (Exception e) + { + Fail(e.Message); + } + } + + public void Fail(string message) => + _completion.TrySetResult(LoginResult.Failure("UNKNOWN", message)); + } + public sealed class BoolContinuation { public AndroidJavaObject Proxy { get; } diff --git a/com.onesignal.unity.core/Editor/Platform/OneSignalNative.cs b/com.onesignal.unity.core/Editor/Platform/OneSignalNative.cs index 84cd07c61..cda4354bf 100755 --- a/com.onesignal.unity.core/Editor/Platform/OneSignalNative.cs +++ b/com.onesignal.unity.core/Editor/Platform/OneSignalNative.cs @@ -115,6 +115,12 @@ public override void Initialize(string appId) public override void Login(string externalId, string jwtBearerToken = null) { } + public override Task LoginWithProfileAsync( + string externalId, + OneSignalUserProfile profile, + string jwtBearerToken = null + ) => Task.FromResult(LoginResult.Unsupported()); + public override void Logout() { } } } diff --git a/com.onesignal.unity.core/Runtime/OneSignal.cs b/com.onesignal.unity.core/Runtime/OneSignal.cs index a4b905fa9..8136e58a0 100755 --- a/com.onesignal.unity.core/Runtime/OneSignal.cs +++ b/com.onesignal.unity.core/Runtime/OneSignal.cs @@ -26,6 +26,7 @@ */ using System.Collections.Generic; +using System.Threading.Tasks; using OneSignalSDK.Debug; using OneSignalSDK.InAppMessages; using OneSignalSDK.LiveActivities; @@ -175,6 +176,19 @@ public static void Login(string externalId, string jwtBearerToken = null) OneSignal.Default.Login(externalId, jwtBearerToken); } + /// + /// Logs in [externalId] and applies [profile] in one operation. Android only for now: + /// on other platforms the task completes with a failed result and the native SDK is not called. + /// + public static Task LoginWithProfileAsync( + string externalId, + OneSignalUserProfile profile, + string jwtBearerToken = null + ) + { + return OneSignal.Default.LoginWithProfileAsync(externalId, profile, jwtBearerToken); + } + /// /// Logout the user previously logged in via [login]. The [user] property now references /// a new device-scoped user. A device-scoped user has no user identity that can later diff --git a/com.onesignal.unity.core/Runtime/OneSignalPlatform.cs b/com.onesignal.unity.core/Runtime/OneSignalPlatform.cs index 01aede8bb..a7e67187b 100644 --- a/com.onesignal.unity.core/Runtime/OneSignalPlatform.cs +++ b/com.onesignal.unity.core/Runtime/OneSignalPlatform.cs @@ -27,6 +27,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using OneSignalSDK.Debug; using OneSignalSDK.InAppMessages; using OneSignalSDK.LiveActivities; @@ -147,6 +148,16 @@ protected static void _init(string appId) /// public abstract void Login(string externalId, string jwtBearerToken = null); + /// + /// Logs in [externalId] and applies [profile] in one operation. Android only for now: + /// on other platforms the task completes with a failed result and the native SDK is not called. + /// + public abstract Task LoginWithProfileAsync( + string externalId, + OneSignalUserProfile profile, + string jwtBearerToken = null + ); + /// /// Logout the user previously logged in via [login]. The [user] property now references /// a new device-scoped user. A device-scoped user has no user identity that can later diff --git a/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs new file mode 100644 index 000000000..b7dc191a7 --- /dev/null +++ b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; + +namespace OneSignalSDK.User +{ + public sealed class LoginData + { + public string OnesignalId { get; } + public string ExternalId { get; } + public string EmailSubscriptionId { get; } + public string SmsSubscriptionId { get; } + + internal LoginData( + string onesignalId, + string externalId, + string emailSubscriptionId, + string smsSubscriptionId + ) + { + OnesignalId = onesignalId; + ExternalId = externalId; + EmailSubscriptionId = emailSubscriptionId; + SmsSubscriptionId = smsSubscriptionId; + } + } + + public sealed class OneSignalError + { + /// + /// One of BACKEND_ERROR, INVALID_ARGUMENT, NOT_INITIALIZED, STORAGE_LOCKED, UNKNOWN. + /// + public string Code { get; } + + /// CLIENT or BACKEND. + public string Source { get; } + public string BackendCode { get; } + public int? HttpStatus { get; } + public string Message { get; } + public IReadOnlyDictionary Meta { get; } + + internal OneSignalError( + string code, + string source, + string backendCode, + int? httpStatus, + string message, + IReadOnlyDictionary meta + ) + { + Code = code; + Source = source; + BackendCode = backendCode; + HttpStatus = httpStatus; + Message = message; + Meta = meta; + } + } + + public sealed class LoginResult + { + internal const string UnsupportedPlatformMessage = + "loginWithProfile is only supported on Android"; + + public bool Success { get; } + + /// Null unless is true. + public LoginData Data { get; } + + /// Empty on success, never empty on failure. + public IReadOnlyList Errors { get; } + + private LoginResult(bool success, LoginData data, IReadOnlyList errors) + { + Success = success; + Data = data; + Errors = errors; + } + + internal static LoginResult Failure(string code, string message) => + new LoginResult( + false, + null, + new[] { new OneSignalError(code, "CLIENT", null, null, message, null) } + ); + + internal static LoginResult Unsupported() => Failure("UNKNOWN", UnsupportedPlatformMessage); + + /// Returns an INVALID_ARGUMENT failure, or null when the inputs are usable. + internal static LoginResult ValidateInputs(string externalId, OneSignalUserProfile profile) + { + const string api = "loginWithProfile"; + if (InputGuard.IsMissing(externalId, api + ": externalId")) + return Failure("INVALID_ARGUMENT", "externalId is required"); + if (InputGuard.IsNull(profile, api + ": profile")) + return Failure("INVALID_ARGUMENT", "profile is required"); + if ( + profile.Tags != null + && InputGuard.HasMissingEntries(profile.Tags, api + ": tags", true) + ) + return Failure("INVALID_ARGUMENT", "tags must not contain null entries"); + if ( + profile.Aliases != null + && InputGuard.HasMissingEntries(profile.Aliases, api + ": aliases", false) + ) + return Failure("INVALID_ARGUMENT", "aliases must not contain empty entries"); + return null; + } + + internal static LoginResult FromJson(string json) + { + Dictionary root; + try + { + root = Json.Deserialize(json) as Dictionary; + } + catch (Exception) + { + root = null; + } + + if (root == null || !(_get(root, "success") is bool success)) + return Failure("UNKNOWN", "Malformed loginWithProfile result"); + + if (success) + { + var data = _parseData(_get(root, "data")); + return data != null + ? new LoginResult(true, data, Array.Empty()) + : Failure("UNKNOWN", "Malformed loginWithProfile result data"); + } + + var errors = new List(); + if (_get(root, "error") is List entries) + { + foreach (var entry in entries) + errors.Add(_parseError(entry)); + } + + return errors.Count > 0 + ? new LoginResult(false, null, errors) + : Failure("UNKNOWN", "loginWithProfile failed without an error"); + } + + private static LoginData _parseData(object value) + { + if ( + !(value is Dictionary data) + || !(_get(data, "onesignalId") is string onesignalId) + || !(_get(data, "externalId") is string externalId) + ) + return null; + + return new LoginData( + onesignalId, + externalId, + _get(data, "emailSubscriptionId") as string, + _get(data, "smsSubscriptionId") as string + ); + } + + private static OneSignalError _parseError(object value) + { + if (!(value is Dictionary error)) + return new OneSignalError( + "UNKNOWN", + "CLIENT", + null, + null, + "Malformed loginWithProfile error", + null + ); + + int? httpStatus = null; + switch (_get(error, "httpStatus")) + { + case long l when l >= int.MinValue && l <= int.MaxValue: + httpStatus = (int)l; + break; + case double d when d >= int.MinValue && d <= int.MaxValue: + httpStatus = (int)d; + break; + } + + return new OneSignalError( + _get(error, "code") as string ?? "UNKNOWN", + _get(error, "source") as string ?? "CLIENT", + _get(error, "backendCode") as string, + httpStatus, + _get(error, "message") as string ?? "", + _get(error, "meta") as Dictionary + ); + } + + private static object _get(Dictionary source, string key) => + source.TryGetValue(key, out var value) ? value : null; + } +} diff --git a/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs.meta b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs.meta new file mode 100644 index 000000000..eb03b255a --- /dev/null +++ b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae5425d9a7594bdfad50f3de6dc22ed3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs b/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs new file mode 100644 index 000000000..a1049c8fe --- /dev/null +++ b/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace OneSignalSDK.User +{ + /// + /// User data applied together with a login. Blank email and SMS values are treated as absent. + /// + public sealed class OneSignalUserProfile + { + public string Email { get; set; } + public string Sms { get; set; } + public Dictionary Tags { get; set; } + public Dictionary Aliases { get; set; } + } +} diff --git a/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs.meta b/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs.meta new file mode 100644 index 000000000..d69680c18 --- /dev/null +++ b/com.onesignal.unity.core/Runtime/User/Models/OneSignalUserProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fabd7f982e1a4391b40a013e81aae42e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.onesignal.unity.ios/Runtime/OneSignaliOS.cs b/com.onesignal.unity.ios/Runtime/OneSignaliOS.cs index e7d6eca85..b10fa3721 100644 --- a/com.onesignal.unity.ios/Runtime/OneSignaliOS.cs +++ b/com.onesignal.unity.ios/Runtime/OneSignaliOS.cs @@ -197,6 +197,12 @@ public override void Login(string externalId, string jwtBearerToken = null) } } + public override Task LoginWithProfileAsync( + string externalId, + OneSignalUserProfile profile, + string jwtBearerToken = null + ) => Task.FromResult(LoginResult.Unsupported()); + public override void Logout() { _oneSignalLogout(); diff --git a/examples/build.md b/examples/build.md index 9594a4718..99a853c11 100644 --- a/examples/build.md +++ b/examples/build.md @@ -69,6 +69,7 @@ using OneSignalSDK.Debug.Models; | Operation | SDK Call | |---|---| | LoginUser(externalUserId) | `OneSignal.Login(externalUserId)` | +| LoginUserWithProfile(externalUserId, email, sms, jwt) | `await OneSignal.LoginWithProfileAsync(externalUserId, profile, jwt)` (Android only) | | LogoutUser() | `OneSignal.Logout()` | | AddAlias(label, id) | `OneSignal.User.AddAlias(label, id)` | | AddAliases(aliases) | `OneSignal.User.AddAliases(aliases)` | diff --git a/examples/demo/Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml b/examples/demo/Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml index d6ca44396..d6cf720fb 100644 --- a/examples/demo/Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml +++ b/examples/demo/Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml @@ -3,6 +3,6 @@ https://repo.maven.apache.org/maven2 - + diff --git a/examples/demo/Assets/Plugins/Android/mainTemplate.gradle b/examples/demo/Assets/Plugins/Android/mainTemplate.gradle index cce5eb394..90c8d3a2c 100644 --- a/examples/demo/Assets/Plugins/Android/mainTemplate.gradle +++ b/examples/demo/Assets/Plugins/Android/mainTemplate.gradle @@ -8,7 +8,7 @@ apply from: '../shared/common.gradle' dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) // Android Resolver Dependencies Start - implementation 'com.onesignal:OneSignal:5.10.2' // Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml:6 + implementation 'com.onesignal:OneSignal:5.11.0' // Assets/OneSignal/Editor/OneSignalAndroidDependencies.xml:6 // Android Resolver Dependencies End **DEPS**} diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs new file mode 100644 index 000000000..418c4edd7 --- /dev/null +++ b/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs @@ -0,0 +1,74 @@ +using System; +using UnityEngine.UIElements; + +namespace OneSignalDemo.UI.Dialogs +{ + public class LoginWithProfileDialog : DialogBase + { + private readonly Action _onConfirm; + private TextField _externalIdField; + private TextField _emailField; + private TextField _smsField; + private TextField _jwtField; + private Button _confirmButton; + + public LoginWithProfileDialog(Action onConfirm) + { + _onConfirm = onConfirm; + } + + protected override void BuildContent(VisualElement container) + { + var title = new Label("Login With Profile"); + title.AddToClassList("dialog-title"); + title.AddToClassList("text-dialog-title"); + container.Add(title); + + _externalIdField = CreateTextField("External User Id", "composite_login_user_id_input"); + _externalIdField.RegisterValueChangedCallback(_ => ValidateInput()); + container.Add(_externalIdField); + + _emailField = CreateTextField("Email (optional)", "composite_login_email_input"); + container.Add(_emailField); + + _smsField = CreateTextField("SMS (optional)", "composite_login_sms_input"); + container.Add(_smsField); + + _jwtField = CreateTextField("JWT Token (optional)", "composite_login_jwt_input"); + container.Add(_jwtField); + + var actions = new VisualElement(); + actions.AddToClassList("dialog-actions"); + + actions.Add(CreateCancelButton()); + + _confirmButton = CreateConfirmButton("Login", OnConfirm); + _confirmButton.name = "composite_login_confirm_button"; + _confirmButton.SetEnabled(false); + actions.Add(_confirmButton); + + container.Add(actions); + } + + private void ValidateInput() + { + _confirmButton?.SetEnabled(!string.IsNullOrWhiteSpace(_externalIdField?.value)); + } + + private void OnConfirm() + { + var externalId = _externalIdField?.value?.Trim(); + if (string.IsNullOrEmpty(externalId)) + return; + + _externalIdField?.Blur(); + _onConfirm?.Invoke( + externalId, + _emailField?.value?.Trim(), + _smsField?.value?.Trim(), + _jwtField?.value?.Trim() + ); + Dismiss(); + } + } +} diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs.meta b/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs.meta new file mode 100644 index 000000000..8fb74f862 --- /dev/null +++ b/examples/demo/Assets/Scripts/UI/Dialogs/LoginWithProfileDialog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c04d94be0a044ed9f99cfb1579ed128 \ No newline at end of file diff --git a/examples/demo/Assets/Scripts/UI/Sections/UserSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/UserSectionController.cs index 3968026bd..952e1737e 100644 --- a/examples/demo/Assets/Scripts/UI/Sections/UserSectionController.cs +++ b/examples/demo/Assets/Scripts/UI/Sections/UserSectionController.cs @@ -57,6 +57,16 @@ private VisualElement BuildSection() ); section.Add(_loginButton); +#if UNITY_ANDROID + section.Add( + SectionBuilder.CreateDestructiveButton( + "LOGIN WITH PROFILE", + "composite_login_button", + ShowLoginWithProfileDialog + ) + ); +#endif + _logoutButton = SectionBuilder.CreateDestructiveButton( "LOGOUT USER", "logout_user_button", @@ -79,6 +89,15 @@ private void ShowLoginDialog() dialog.Show(_dialogRoot); } + private void ShowLoginWithProfileDialog() + { + var dialog = new LoginWithProfileDialog( + (externalId, email, sms, jwt) => + _viewModel.LoginUserWithProfile(externalId, email, sms, jwt) + ); + dialog.Show(_dialogRoot); + } + public void Refresh() { _statusValue.text = _viewModel.IsLoggedIn ? "Logged In" : "Anonymous"; diff --git a/examples/demo/Assets/Scripts/ViewModels/AppViewModel.cs b/examples/demo/Assets/Scripts/ViewModels/AppViewModel.cs index 21df63a58..32217555f 100644 --- a/examples/demo/Assets/Scripts/ViewModels/AppViewModel.cs +++ b/examples/demo/Assets/Scripts/ViewModels/AppViewModel.cs @@ -161,6 +161,58 @@ public void LoginUser(string externalUserId) NotifyStateChanged(); } + public async void LoginUserWithProfile( + string externalUserId, + string email, + string sms, + string jwtBearerToken + ) + { + externalUserId = externalUserId?.Trim(); + if (string.IsNullOrEmpty(externalUserId)) + return; + + ClearUserData(); + SetLoading(true); + + try + { + var profile = new OneSignalUserProfile + { + Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(), + Sms = string.IsNullOrWhiteSpace(sms) ? null : sms.Trim(), + }; + var result = await OneSignal.LoginWithProfileAsync( + externalUserId, + profile, + string.IsNullOrWhiteSpace(jwtBearerToken) ? null : jwtBearerToken.Trim() + ); + + if (!result.Success) + { + foreach (var error in result.Errors) + Debug.LogError( + $"[{Tag}] Login with profile error: {error.Code} ({error.Source}) {error.Message}" + ); + SetLoading(false); + return; + } + + _prefs.ExternalUserId = externalUserId; + _externalUserId = externalUserId; + Debug.Log($"[{Tag}] Logged in with profile as: {externalUserId}"); + NotifyStateChanged(); + + // The user 'change' listener does not fire when the external id is unchanged. + await FetchUserDataFromApi(); + } + catch (Exception ex) + { + Debug.LogError($"[{Tag}] Login with profile error: {ex.Message}"); + SetLoading(false); + } + } + public void LogoutUser() { OneSignal.Logout(); diff --git a/examples/demo/ProjectSettings/AndroidResolverDependencies.xml b/examples/demo/ProjectSettings/AndroidResolverDependencies.xml index 35bc23a24..331a0be10 100644 --- a/examples/demo/ProjectSettings/AndroidResolverDependencies.xml +++ b/examples/demo/ProjectSettings/AndroidResolverDependencies.xml @@ -1,6 +1,6 @@ - com.onesignal:OneSignal:5.10.2 + com.onesignal:OneSignal:5.11.0 From 755bad42d87f576e178b2e17115a73bf95f44fa9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 13 Oct 2026 11:07:23 -0500 Subject: [PATCH 2/3] fix: [SDK-5024] harden LoginWithProfileAsync from review Rename LoginData.OnesignalId to OneSignalId to match User.OneSignalId. Build the Consumer proxy inside the guard so it can never throw, log native failures once, use the exception message, and reject success with errors. Co-authored-by: Cursor --- .../Runtime/OneSignalAndroid.cs | 12 +++++++++++- .../Runtime/Utilities/AndroidJavaProxy.cs | 14 +++++++++++--- .../Runtime/User/Models/LoginResult.cs | 7 ++++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs index a707c4b0a..3e4071b7f 100644 --- a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs +++ b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs @@ -219,7 +219,17 @@ public override Task LoginWithProfileAsync( if (invalid != null) return Task.FromResult(invalid); - var continuation = new LoginResultContinuation(); + LoginResultContinuation continuation; + try + { + continuation = new LoginResultContinuation(); + } + catch (Exception e) + { + SDKDebug.Error("loginWithProfile failed: " + e.Message); + return Task.FromResult(LoginResult.Failure("UNKNOWN", e.Message)); + } + try { // Resolved before the call so nothing after it can fail and mask a native result. diff --git a/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs b/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs index 43745d9e2..500a277ee 100644 --- a/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs +++ b/com.onesignal.unity.android/Runtime/Utilities/AndroidJavaProxy.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using Laters; +using OneSignalSDK.Debug.Utilities; using OneSignalSDK.User; using UnityEngine; @@ -101,7 +102,11 @@ public void accept(AndroidJavaObject continueResult) else { using var throwable = continueResult.Call("getThrowable"); - Fail(throwable?.Call("toString") ?? "loginWithProfile failed"); + Fail( + throwable?.Call("getMessage") + ?? throwable?.Call("toString") + ?? "loginWithProfile failed" + ); } } catch (Exception e) @@ -124,8 +129,11 @@ public void Resolve(AndroidJavaObject oneSignalResult) } } - public void Fail(string message) => - _completion.TrySetResult(LoginResult.Failure("UNKNOWN", message)); + public void Fail(string message) + { + if (_completion.TrySetResult(LoginResult.Failure("UNKNOWN", message))) + SDKDebug.Error("loginWithProfile failed: " + message); + } } public sealed class BoolContinuation diff --git a/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs index b7dc191a7..b4168d389 100644 --- a/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs +++ b/com.onesignal.unity.core/Runtime/User/Models/LoginResult.cs @@ -5,7 +5,7 @@ namespace OneSignalSDK.User { public sealed class LoginData { - public string OnesignalId { get; } + public string OneSignalId { get; } public string ExternalId { get; } public string EmailSubscriptionId { get; } public string SmsSubscriptionId { get; } @@ -17,7 +17,7 @@ internal LoginData( string smsSubscriptionId ) { - OnesignalId = onesignalId; + OneSignalId = onesignalId; ExternalId = externalId; EmailSubscriptionId = emailSubscriptionId; SmsSubscriptionId = smsSubscriptionId; @@ -124,7 +124,8 @@ internal static LoginResult FromJson(string json) if (success) { var data = _parseData(_get(root, "data")); - return data != null + var strayErrors = _get(root, "error") is List stray && stray.Count > 0; + return data != null && !strayErrors ? new LoginResult(true, data, Array.Empty()) : Failure("UNKNOWN", "Malformed loginWithProfile result data"); } From 9d6f2539643e37c57857dda85d8612741407184d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 14 Oct 2026 12:38:59 -0500 Subject: [PATCH 3/3] fix: [SDK-5024] build OneSignalUserProfile through its Builder The native constructor is now private so new profile fields never break compiled callers. Co-authored-by: Cursor --- .../Runtime/OneSignalAndroid.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs index 3e4071b7f..966c6eb19 100644 --- a/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs +++ b/com.onesignal.unity.android/Runtime/OneSignalAndroid.cs @@ -236,13 +236,15 @@ public override Task LoginWithProfileAsync( using var resultClass = new AndroidJavaClass(SDKPackage + ".OneSignalResult"); using var tags = (profile.Tags ?? new Dictionary()).ToMap(); using var aliases = (profile.Aliases ?? new Dictionary()).ToMap(); - using var nativeProfile = new AndroidJavaObject( - SDKPackage + ".OneSignalUserProfile", - profile.Email, - profile.Sms, - tags, - aliases - ); + using var profileClass = new AndroidJavaClass(SDKPackage + ".OneSignalUserProfile"); + using var builder = profileClass.CallStatic("builder"); + if (profile.Email != null) + builder.Call("setEmail", profile.Email)?.Dispose(); + if (profile.Sms != null) + builder.Call("setSms", profile.Sms)?.Dispose(); + builder.Call("setTags", tags)?.Dispose(); + builder.Call("setAliases", aliases)?.Dispose(); + using var nativeProfile = builder.Call("build"); using var proxy = continuation.CreateProxy(); using var returned = _sdkClass.CallStatic( "login",