From 33da58eacdf49a8c0b7d39b5fd4d4ca2df8a6352 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Thu, 13 Aug 2026 23:52:10 +0300 Subject: [PATCH 1/3] fix: marshal native alert callbacks to Unity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return platform callbacks to the synchronization context captured when an alert is shown so consumers can safely touch Unity state. RCR: MarshalCallbackToContext_ForeignThread_PostsBeforeInvoking ← NativeUiService.MarshalCallbackToContext invokes callback directly Co-authored-by: Cursor --- AGENTS.md | 2 +- CHANGELOG.md | 5 +++ README.md | 2 +- Runtime/NativeUi/NativeUiService.cs | 45 +++++++++++++++++++++- Tests/EditMode/Unit/NativeUiServiceTest.cs | 42 ++++++++++++++++++++ docs/native-ui.md | 2 + 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cef1fb..f2e5547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This guide adds package-specific rules to the host repository guide. Consumer us ## Runtime invariants -- Native alerts accept one to three buttons with unique text and unique `AlertButtonStyle` values. A non-dismissible alert cannot be an action sheet because iOS retains outside-tap dismissal for that presentation style. +- Native alerts accept one to three buttons with unique text and unique `AlertButtonStyle` values. A non-dismissible alert cannot be an action sheet because iOS retains outside-tap dismissal for that presentation style. Calls originate on Unity's main thread; every platform callback returns to the synchronization context captured when the alert was shown. - `RequestReview()` is fire-and-forget. The OS may suppress the prompt and provides no “shown” result; do not invent success callbacks or a store-URL fallback. Android Play review depends on the configured Play Core review artifact, which the native-build postprocessor injects unless explicitly disabled. - `MobileNotificationService` owns its `NotificationService` GameObject. Disposal is idempotent, releases only owned resources, and public operations depending on the host throw `ObjectDisposedException` afterward. Shared validation and lifecycle behavior stay outside platform conditionals. - Android notification scheduling requires a registered default channel. Queue modes hand pending work to the OS on background transitions. diff --git a/CHANGELOG.md b/CHANGELOG.md index abe4457..cf373bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +**Fixed**: +- Alert button callbacks now return to Unity's captured synchronization context before invoking consumer code. + ## [1.1.0] - 2026-08-13 **New**: diff --git a/README.md b/README.md index 4a57c01..6536d95 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Use the specific subsystem namespaces—`Notifications`, `Haptics`, `NativeUi`, | Gestures | Gesture controller for explicit gesture input ownership | | Editor tooling | Device Simulator integration and build helpers | -Runtime alert calls render an interactive platform-shaped mock in the Game view even when the Device Simulator window is closed. The editor simulator is for exercising application paths; it is not a substitute for device permission, notification-delivery, review, or native-build validation. +Runtime alert calls render an interactive platform-shaped mock in the Game view even when the Device Simulator window is closed, and alert callbacks return to Unity's captured synchronization context on every platform. The editor simulator is for exercising application paths; it is not a substitute for device permission, notification-delivery, review, or native-build validation. ## Sample and support diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs index a8b5c45..e524829 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; #if UNITY_ANDROID using System.Collections.Generic; #endif @@ -68,7 +69,8 @@ public static void ShowAlertPopUp(bool isAlertSheet, string title, string messag /// /// A non-dismissible alert must use alert style, not action-sheet style. Alerts support one /// to three buttons with unique labels and styles so the same descriptors map safely on iOS - /// and Android. + /// and Android. Call this API from Unity's main thread; button callbacks return to its captured + /// synchronization context before invocation. /// public static void ShowAlertPopUp( bool isAlertSheet, @@ -78,6 +80,7 @@ public static void ShowAlertPopUp( params AlertButton[] buttons) { ValidateAlert(isAlertSheet, isDismissible, buttons); + buttons = MarshalButtonCallbacks(buttons); #if UNITY_EDITOR if (EditorShowAlertOverride != null) @@ -216,6 +219,46 @@ public static void Share(string text, string url = null, string imagePath = null #endif } + /// + /// Wraps a callback so a foreign platform thread posts it to the context captured by the caller. + /// + internal static Action MarshalCallbackToContext( + Action callback, + SynchronizationContext context, + int sourceThreadId) + { + if (callback == null) + { + return null; + } + + return () => + { + if (Thread.CurrentThread.ManagedThreadId == sourceThreadId || context == null) + { + callback(); + return; + } + + context.Post(_ => callback(), null); + }; + } + + private static AlertButton[] MarshalButtonCallbacks(AlertButton[] buttons) + { + var context = SynchronizationContext.Current; + int sourceThreadId = Thread.CurrentThread.ManagedThreadId; + var marshalledButtons = new AlertButton[buttons.Length]; + for (var i = 0; i < buttons.Length; i++) + { + marshalledButtons[i] = buttons[i]; + marshalledButtons[i].Callback = + MarshalCallbackToContext(buttons[i].Callback, context, sourceThreadId); + } + + return marshalledButtons; + } + private static void ValidateAlert(bool isAlertSheet, bool isDismissible, AlertButton[] buttons) { if (!isDismissible && isAlertSheet) diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs b/Tests/EditMode/Unit/NativeUiServiceTest.cs index f75ef9c..2144638 100644 --- a/Tests/EditMode/Unit/NativeUiServiceTest.cs +++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using GameLovers.MobileServices.NativeUi; using NUnit.Framework; using UnityEngine.TestTools; @@ -94,6 +95,28 @@ public void DismissAlertPopUp_EditorOverride_InvokesCallback() Assert.AreEqual(1, callbackCount); } + [Test] + // ADMIT: NativeUiService.MarshalCallbackToContext could invoke an Android UI-thread callback before returning to Unity's context. + // RCR: NativeUiService.cs MarshalCallbackToContext — replace `context.Post(...)` with `callback()` → RED (callback ran before context drain). 2026-08-13 + public void MarshalCallbackToContext_ForeignThread_PostsBeforeInvoking() + { + var context = new RecordingSynchronizationContext(); + int callbackCount = 0; + Action callback = NativeUiService.MarshalCallbackToContext( + () => callbackCount++, + context, + Thread.CurrentThread.ManagedThreadId); + var thread = new Thread(callback.Invoke); + + thread.Start(); + thread.Join(); + + Assert.AreEqual(0, callbackCount); + Assert.AreEqual(1, context.PostCount); + context.ExecutePostedCallback(); + Assert.AreEqual(1, callbackCount); + } + [Test] // ADMIT: NativeUiService.ShowToastMessage could change the Editor diagnostic that stands in for the native toast. // RCR: NativeUiService.cs ShowToastMessage — editor log text `Show Toast message` → `Show Toast msg` → RED (LogAssert expected message not received). @@ -134,5 +157,24 @@ public void Share_NullOptionalArgs_InEditor_DoesNotThrow() Assert.DoesNotThrow(() => NativeUiService.Share("hi")); } + private sealed class RecordingSynchronizationContext : SynchronizationContext + { + private SendOrPostCallback _callback; + private object _state; + + public int PostCount { get; private set; } + + public override void Post(SendOrPostCallback callback, object state) + { + PostCount++; + _callback = callback; + _state = state; + } + + public void ExecutePostedCallback() + { + _callback(_state); + } + } } } diff --git a/docs/native-ui.md b/docs/native-ui.md index 9acb814..0b842e2 100644 --- a/docs/native-ui.md +++ b/docs/native-ui.md @@ -46,6 +46,8 @@ public enum AlertButtonStyle Alerts accept one to three buttons. Labels and styles must each be unique within an alert: iOS matches callbacks by button text, while Android maps the three styles onto its three native button slots. +Call `ShowAlertPopUp` from Unity's main thread. Every `AlertButton.Callback` returns to the synchronization context captured by that call before it invokes consumer code, including Android callbacks originating on the OS UI thread. + The overload without `isDismissible` preserves the original dismissible behavior. Set `isDismissible: false` for blocking alerts that must remain until the user selects a button; non-dismissible action sheets are rejected because iOS action sheets can be dismissed outside the sheet. `DismissAlertPopUp()` closes the active alert without invoking an action. Showing a new alert replaces the current one. From fb26a39d45398dda30fdb9ea315017834b800bd7 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 14 Aug 2026 01:02:25 +0300 Subject: [PATCH 2/3] feat: add awaitable native alerts --- AGENTS.md | 2 +- CHANGELOG.md | 5 +- README.md | 4 +- Runtime/NativeUi/INativeUiService.cs | 19 ++ Runtime/NativeUi/NativeUiService.cs | 227 +++++++++++++----- Samples~/MobileServicesSamples/README.md | 2 + .../Unit/NativeUiServiceInstanceTest.cs | 20 ++ Tests/EditMode/Unit/NativeUiServiceTest.cs | 55 ++--- .../Unit/NativeUiServicePlayModeTest.cs | 94 ++++++++ .../Unit/NativeUiServicePlayModeTest.cs.meta | 2 + docs/native-ui.md | 15 +- 11 files changed, 341 insertions(+), 104 deletions(-) create mode 100644 Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs create mode 100644 Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs.meta diff --git a/AGENTS.md b/AGENTS.md index f2e5547..931fdbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This guide adds package-specific rules to the host repository guide. Consumer us ## Runtime invariants -- Native alerts accept one to three buttons with unique text and unique `AlertButtonStyle` values. A non-dismissible alert cannot be an action sheet because iOS retains outside-tap dismissal for that presentation style. Calls originate on Unity's main thread; every platform callback returns to the synchronization context captured when the alert was shown. +- Native alerts accept one to three buttons with unique text and unique `AlertButtonStyle` values. A non-dismissible alert cannot be an action sheet because iOS retains outside-tap dismissal for that presentation style. `ShowAlertPopUpAsync` is the preferred API; it returns the selected index through one pooled `Awaitable`, while legacy callbacks switch through `Awaitable.MainThreadAsync`. Dismissing or replacing an async alert cancels its await. - `RequestReview()` is fire-and-forget. The OS may suppress the prompt and provides no “shown” result; do not invent success callbacks or a store-URL fallback. Android Play review depends on the configured Play Core review artifact, which the native-build postprocessor injects unless explicitly disabled. - `MobileNotificationService` owns its `NotificationService` GameObject. Disposal is idempotent, releases only owned resources, and public operations depending on the host throw `ObjectDisposedException` afterward. Shared validation and lifecycle behavior stay outside platform conditionals. - Android notification scheduling requires a registered default channel. Queue modes hand pending work to the OS on background transitions. diff --git a/CHANGELOG.md b/CHANGELOG.md index cf373bc..ffd9ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +**New**: +- Added `ShowAlertPopUpAsync`, which returns the selected button index through Unity's `Awaitable` API. + **Fixed**: -- Alert button callbacks now return to Unity's captured synchronization context before invoking consumer code. +- Alert button callbacks now return through `Awaitable.MainThreadAsync` before invoking consumer code. ## [1.1.0] - 2026-08-13 diff --git a/README.md b/README.md index 6536d95..ab173d9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Use Mobile Services to isolate platform-specific behavior behind Unity-friendly ```json { "dependencies": { - "com.gamelovers.mobileservices": "https://github.com/CoderGamester/Unity-MobileServices.git#1.0.1" + "com.gamelovers.mobileservices": "https://github.com/CoderGamester/Unity-MobileServices.git#1.2.0" } } ``` @@ -79,7 +79,7 @@ Use the specific subsystem namespaces—`Notifications`, `Haptics`, `NativeUi`, | Gestures | Gesture controller for explicit gesture input ownership | | Editor tooling | Device Simulator integration and build helpers | -Runtime alert calls render an interactive platform-shaped mock in the Game view even when the Device Simulator window is closed, and alert callbacks return to Unity's captured synchronization context on every platform. The editor simulator is for exercising application paths; it is not a substitute for device permission, notification-delivery, review, or native-build validation. +Runtime alert calls render an interactive platform-shaped mock in the Game view even when the Device Simulator window is closed. Prefer `ShowAlertPopUpAsync` for a selected-index result on Unity's main thread; legacy callbacks also switch through Unity's `Awaitable` scheduler. The editor simulator is for exercising application paths; it is not a substitute for device permission, notification-delivery, review, or native-build validation. ## Sample and support diff --git a/Runtime/NativeUi/INativeUiService.cs b/Runtime/NativeUi/INativeUiService.cs index 38047e8..de21cc5 100644 --- a/Runtime/NativeUi/INativeUiService.cs +++ b/Runtime/NativeUi/INativeUiService.cs @@ -1,3 +1,5 @@ +using UnityEngine; + // ReSharper disable once CheckNamespace namespace GameLovers.MobileServices.NativeUi { @@ -20,6 +22,14 @@ void ShowAlertPopUp( string message, params AlertButton[] buttons); + /// + Awaitable ShowAlertPopUpAsync( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons); + /// void DismissAlertPopUp(); @@ -52,6 +62,15 @@ public void ShowAlertPopUp( params AlertButton[] buttons) => NativeUiService.ShowAlertPopUp(isAlertSheet, isDismissible, title, message, buttons); + /// + public Awaitable ShowAlertPopUpAsync( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons) + => NativeUiService.ShowAlertPopUpAsync(isAlertSheet, isDismissible, title, message, buttons); + /// public void DismissAlertPopUp() => NativeUiService.DismissAlertPopUp(); diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs index e524829..6167be0 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -1,5 +1,4 @@ using System; -using System.Threading; #if UNITY_ANDROID using System.Collections.Generic; #endif @@ -34,6 +33,9 @@ public struct AlertButton /// public static class NativeUiService { + private static readonly object _alertCompletionLock = new object(); + private static AwaitableCompletionSource _currentAlertCompletion; + #if UNITY_IOS /// Native iOS callback signature; the button is identified by its text. internal delegate void AlertButtonDelegate(string buttonText); @@ -69,8 +71,8 @@ public static void ShowAlertPopUp(bool isAlertSheet, string title, string messag /// /// A non-dismissible alert must use alert style, not action-sheet style. Alerts support one /// to three buttons with unique labels and styles so the same descriptors map safely on iOS - /// and Android. Call this API from Unity's main thread; button callbacks return to its captured - /// synchronization context before invocation. + /// and Android. Call this API from Unity's main thread; button callbacks return there before + /// invocation. /// public static void ShowAlertPopUp( bool isAlertSheet, @@ -80,42 +82,31 @@ public static void ShowAlertPopUp( params AlertButton[] buttons) { ValidateAlert(isAlertSheet, isDismissible, buttons); - buttons = MarshalButtonCallbacks(buttons); - -#if UNITY_EDITOR - if (EditorShowAlertOverride != null) - { - EditorShowAlertOverride.Invoke(isAlertSheet, isDismissible, title, message, buttons); - } - else - { - Debug.Log($"Show Alert Pop Up is not available in the editor and was triggered with: {title} - {message}"); - } -#elif UNITY_IOS - _currentButtons = buttons; - - var buttonsText = new string[buttons.Length]; - var buttonsStyle = new int[buttons.Length]; - - for (var i = 0; i < buttons.Length; i++) - { - buttonsText[i] = buttons[i].Text; - buttonsStyle[i] = (int) buttons[i].Style; - } + ObserveLegacyAlert(BeginAlert(isAlertSheet, isDismissible, title, message, buttons)); + } - AlertMessage( - isAlertSheet, - title, - message, - buttonsText, - buttonsStyle, - buttons.Length, - AlertButtonCallback); -#elif UNITY_ANDROID - ShowAlertAndroid(isDismissible, title, message, buttons); -#else - throw new SystemException("Show an alert Pop Up is only available for iOS and Android platforms"); -#endif + /// + /// Shows an alert and completes with the selected button index on Unity's main thread. + /// + /// + /// Call from Unity's main thread and await the returned value once. Existing button callbacks + /// still run before the result is returned. Dismissing or replacing the alert cancels the await. + /// + /// Whether iOS presents an action sheet instead of an alert. + /// Whether the alert can close without selecting a button. + /// Alert title. + /// Alert message. + /// Ordered alert actions. + /// An awaitable containing the selected zero-based button index. + public static Awaitable ShowAlertPopUpAsync( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons) + { + ValidateAlert(isAlertSheet, isDismissible, buttons); + return BeginAlert(isAlertSheet, isDismissible, title, message, buttons); } /// @@ -154,6 +145,7 @@ public static void ShowToastMessage(string message, bool isLongDuration) /// public static void DismissAlertPopUp() { + CancelCurrentAlert(); #if UNITY_EDITOR EditorDismissAlertOverride?.Invoke(); #elif UNITY_IOS @@ -219,44 +211,157 @@ public static void Share(string text, string url = null, string imagePath = null #endif } - /// - /// Wraps a callback so a foreign platform thread posts it to the context captured by the caller. - /// - internal static Action MarshalCallbackToContext( - Action callback, - SynchronizationContext context, - int sourceThreadId) + private static AlertButton[] BuildSelectionButtons(AlertButton[] buttons, Action onSelected) + { + var selectionButtons = new AlertButton[buttons.Length]; + for (var i = 0; i < buttons.Length; i++) + { + int buttonIndex = i; + selectionButtons[i] = buttons[i]; + selectionButtons[i].Callback = () => onSelected(buttonIndex); + } + + return selectionButtons; + } + + private static Awaitable BeginAlert( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + AlertButton[] buttons) + { + var completionSource = new AwaitableCompletionSource(); + ReplaceCurrentAlert(completionSource); + AlertButton[] selectionButtons = BuildSelectionButtons( + buttons, + index => CompleteAlert(completionSource, index)); + + try + { + ShowAlertPopUpCore(isAlertSheet, isDismissible, title, message, selectionButtons); + } + catch + { + ClearCurrentAlert(completionSource); + throw; + } + + return CompleteAlertAsync(completionSource, buttons); + } + + private static async Awaitable CompleteAlertAsync( + AwaitableCompletionSource completionSource, + AlertButton[] buttons) + { + int selectedIndex = await completionSource.Awaitable; + await Awaitable.MainThreadAsync(); + buttons[selectedIndex].Callback?.Invoke(); + return selectedIndex; + } + + private static void ShowAlertPopUpCore( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + AlertButton[] buttons) + { +#if UNITY_EDITOR + if (EditorShowAlertOverride != null) + { + EditorShowAlertOverride.Invoke(isAlertSheet, isDismissible, title, message, buttons); + } + else + { + Debug.Log($"Show Alert Pop Up is not available in the editor and was triggered with: {title} - {message}"); + } +#elif UNITY_IOS + _currentButtons = buttons; + + var buttonsText = new string[buttons.Length]; + var buttonsStyle = new int[buttons.Length]; + + for (var i = 0; i < buttons.Length; i++) + { + buttonsText[i] = buttons[i].Text; + buttonsStyle[i] = (int)buttons[i].Style; + } + + AlertMessage( + isAlertSheet, + title, + message, + buttonsText, + buttonsStyle, + buttons.Length, + AlertButtonCallback); +#elif UNITY_ANDROID + ShowAlertAndroid(isDismissible, title, message, buttons); +#else + throw new SystemException("Show an alert Pop Up is only available for iOS and Android platforms"); +#endif + } + + private static async void ObserveLegacyAlert(Awaitable alert) + { + try + { + await alert; + } + catch (OperationCanceledException) + { + } + } + + private static void ReplaceCurrentAlert(AwaitableCompletionSource completionSource) { - if (callback == null) + AwaitableCompletionSource previous; + lock (_alertCompletionLock) { - return null; + previous = _currentAlertCompletion; + _currentAlertCompletion = completionSource; } - return () => + previous?.TrySetCanceled(); + } + + private static void CompleteAlert(AwaitableCompletionSource completionSource, int selectedIndex) + { + lock (_alertCompletionLock) { - if (Thread.CurrentThread.ManagedThreadId == sourceThreadId || context == null) + if (_currentAlertCompletion != completionSource) { - callback(); return; } - context.Post(_ => callback(), null); - }; + _currentAlertCompletion = null; + } + + completionSource.TrySetResult(selectedIndex); } - private static AlertButton[] MarshalButtonCallbacks(AlertButton[] buttons) + private static void ClearCurrentAlert(AwaitableCompletionSource completionSource) { - var context = SynchronizationContext.Current; - int sourceThreadId = Thread.CurrentThread.ManagedThreadId; - var marshalledButtons = new AlertButton[buttons.Length]; - for (var i = 0; i < buttons.Length; i++) + lock (_alertCompletionLock) + { + if (_currentAlertCompletion == completionSource) + { + _currentAlertCompletion = null; + } + } + } + + private static void CancelCurrentAlert() + { + AwaitableCompletionSource completionSource; + lock (_alertCompletionLock) { - marshalledButtons[i] = buttons[i]; - marshalledButtons[i].Callback = - MarshalCallbackToContext(buttons[i].Callback, context, sourceThreadId); + completionSource = _currentAlertCompletion; + _currentAlertCompletion = null; } - return marshalledButtons; + completionSource?.TrySetCanceled(); } private static void ValidateAlert(bool isAlertSheet, bool isDismissible, AlertButton[] buttons) diff --git a/Samples~/MobileServicesSamples/README.md b/Samples~/MobileServicesSamples/README.md index f6ff9c2..8558dbd 100644 --- a/Samples~/MobileServicesSamples/README.md +++ b/Samples~/MobileServicesSamples/README.md @@ -27,6 +27,8 @@ The Overview view covers package areas that are not duplicated by the focused vi The live device card reports battery, low-power, keep-awake, ATT, and safe-area state on separate lines. The Editor and Device Simulator use safe no-op or mock implementations where an operating-system feature is unavailable. +The Overview alert controls keep the callback overload so their activity log can demonstrate legacy integration. For new consumer flows that need the selected zero-based button index, prefer `NativeUiService.ShowAlertPopUpAsync`; dismissal or replacement cancels its `Awaitable`. + ## Haptics The Haptics view exposes every preset—`Selection`, `Success`, `Warning`, `Error`, and the five impact presets—with three duration modes: diff --git a/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs index 30532d3..c5034ba 100644 --- a/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs +++ b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs @@ -29,6 +29,7 @@ public void Init() [TearDown] public void Cleanup() { + NativeUiService.DismissAlertPopUp(); NativeUiService.EditorShowAlertOverride = _showAlertOverride; NativeUiService.EditorDismissAlertOverride = _dismissAlertOverride; } @@ -47,6 +48,25 @@ public void ShowAlertPopUp_ForwardsToStaticService() new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); } + [Test] + // ADMIT: NativeUiServiceInstance.ShowAlertPopUpAsync could fail to forward the selected button result. + // RCR: INativeUiService.cs NativeUiServiceInstance.ShowAlertPopUpAsync — return a never-completed Awaitable → RED (awaiter incomplete). 2026-08-14 + public void ShowAlertPopUpAsync_ForwardsSelectedIndex() + { + NativeUiService.EditorShowAlertOverride = (_, _, _, _, buttons) => buttons[0].Callback(); + + Awaitable operation = _instance.ShowAlertPopUpAsync( + false, + true, + "T", + "M", + new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); + var awaiter = operation.GetAwaiter(); + + Assert.IsTrue(awaiter.IsCompleted); + Assert.AreEqual(0, awaiter.GetResult()); + } + [Test] // ADMIT: NativeUiServiceInstance.DismissAlertPopUp must forward to the static service. // RCR: INativeUiService.cs NativeUiServiceInstance.DismissAlertPopUp — replace the expression body with `{ }` → RED (callback count remains zero). 2026-08-13 diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs b/Tests/EditMode/Unit/NativeUiServiceTest.cs index 2144638..677ad29 100644 --- a/Tests/EditMode/Unit/NativeUiServiceTest.cs +++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs @@ -1,5 +1,4 @@ using System; -using System.Threading; using GameLovers.MobileServices.NativeUi; using NUnit.Framework; using UnityEngine.TestTools; @@ -27,6 +26,7 @@ public void Init() [TearDown] public void Cleanup() { + NativeUiService.DismissAlertPopUp(); NativeUiService.EditorShowAlertOverride = _showAlertOverride; NativeUiService.EditorDismissAlertOverride = _dismissAlertOverride; } @@ -96,25 +96,23 @@ public void DismissAlertPopUp_EditorOverride_InvokesCallback() } [Test] - // ADMIT: NativeUiService.MarshalCallbackToContext could invoke an Android UI-thread callback before returning to Unity's context. - // RCR: NativeUiService.cs MarshalCallbackToContext — replace `context.Post(...)` with `callback()` → RED (callback ran before context drain). 2026-08-13 - public void MarshalCallbackToContext_ForeignThread_PostsBeforeInvoking() + // ADMIT: NativeUiService.DismissAlertPopUp could leave an async alert awaiting forever. + // RCR: NativeUiService.cs DismissAlertPopUp — remove `CancelCurrentAlert()` → RED (awaiter incomplete). 2026-08-14 + public void DismissAlertPopUp_AsyncAlert_CancelsAwait() { - var context = new RecordingSynchronizationContext(); - int callbackCount = 0; - Action callback = NativeUiService.MarshalCallbackToContext( - () => callbackCount++, - context, - Thread.CurrentThread.ManagedThreadId); - var thread = new Thread(callback.Invoke); - - thread.Start(); - thread.Join(); - - Assert.AreEqual(0, callbackCount); - Assert.AreEqual(1, context.PostCount); - context.ExecutePostedCallback(); - Assert.AreEqual(1, callbackCount); + NativeUiService.EditorShowAlertOverride = (_, _, _, _, _) => { }; + Awaitable operation = NativeUiService.ShowAlertPopUpAsync( + false, + true, + "T", + "M", + new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); + var awaiter = operation.GetAwaiter(); + + NativeUiService.DismissAlertPopUp(); + + Assert.IsTrue(awaiter.IsCompleted); + Assert.Throws(() => awaiter.GetResult()); } [Test] @@ -157,24 +155,5 @@ public void Share_NullOptionalArgs_InEditor_DoesNotThrow() Assert.DoesNotThrow(() => NativeUiService.Share("hi")); } - private sealed class RecordingSynchronizationContext : SynchronizationContext - { - private SendOrPostCallback _callback; - private object _state; - - public int PostCount { get; private set; } - - public override void Post(SendOrPostCallback callback, object state) - { - PostCount++; - _callback = callback; - _state = state; - } - - public void ExecutePostedCallback() - { - _callback(_state); - } - } } } diff --git a/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs b/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs new file mode 100644 index 0000000..5381eb3 --- /dev/null +++ b/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections; +using System.Threading; +using GameLovers.MobileServices.NativeUi; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace +namespace GameLoversEditor.MobileServices.Tests +{ + public class NativeUiServicePlayModeTest + { + private Action _showAlertOverride; + + [SetUp] + public void Init() + { + _showAlertOverride = NativeUiService.EditorShowAlertOverride; + } + + [TearDown] + public void Cleanup() + { + NativeUiService.DismissAlertPopUp(); + NativeUiService.EditorShowAlertOverride = _showAlertOverride; + } + + [UnityTest] + // ADMIT: NativeUiService.ShowAlertPopUp could stop observing the shared async alert path and lose legacy callbacks. + // RCR: NativeUiService.cs ShowAlertPopUp — replace the BeginAlert call with `CancelCurrentAlert()` → RED (native alert was not presented). 2026-08-14 + public IEnumerator ShowAlertPopUp_ForeignSelection_InvokesCallbackOnUnityMainThread() + { + int mainThreadId = Thread.CurrentThread.ManagedThreadId; + int callbackThreadId = 0; + AlertButton[] renderedButtons = null; + NativeUiService.EditorShowAlertOverride = (_, _, _, _, buttons) => renderedButtons = buttons; + + NativeUiService.ShowAlertPopUp( + false, + true, + "Title", + "Message", + new AlertButton + { + Text = "Continue", + Style = AlertButtonStyle.Default, + Callback = () => callbackThreadId = Thread.CurrentThread.ManagedThreadId, + }); + Assert.IsNotNull(renderedButtons, "Native alert was not presented."); + var thread = new Thread(renderedButtons[0].Callback.Invoke); + + thread.Start(); + thread.Join(); + yield return null; + + Assert.AreEqual(mainThreadId, callbackThreadId); + } + + [UnityTest] + // ADMIT: NativeUiService.ShowAlertPopUpAsync could resume consumer code on Android's OS UI thread. + // RCR: NativeUiService.cs CompleteAlertAsync — remove `await Awaitable.MainThreadAsync()` → RED (operation completed on worker thread). 2026-08-14 + public IEnumerator ShowAlertPopUpAsync_ForeignSelection_CompletesOnUnityMainThread() + { + int mainThreadId = Thread.CurrentThread.ManagedThreadId; + int callbackThreadId = 0; + AlertButton[] renderedButtons = null; + NativeUiService.EditorShowAlertOverride = (_, _, _, _, buttons) => renderedButtons = buttons; + Awaitable operation = NativeUiService.ShowAlertPopUpAsync( + false, + true, + "Title", + "Message", + new AlertButton + { + Text = "Continue", + Style = AlertButtonStyle.Default, + Callback = () => callbackThreadId = Thread.CurrentThread.ManagedThreadId, + }); + var awaiter = operation.GetAwaiter(); + var thread = new Thread(renderedButtons[0].Callback.Invoke); + + thread.Start(); + thread.Join(); + + Assert.IsFalse(awaiter.IsCompleted); + yield return null; + + Assert.IsTrue(awaiter.IsCompleted); + Assert.AreEqual(0, awaiter.GetResult()); + Assert.AreEqual(mainThreadId, callbackThreadId); + } + } +} diff --git a/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs.meta b/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs.meta new file mode 100644 index 0000000..f636735 --- /dev/null +++ b/Tests/PlayMode/Unit/NativeUiServicePlayModeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d1a86774f4ab4a42aa9378e400c4522 \ No newline at end of file diff --git a/docs/native-ui.md b/docs/native-ui.md index 0b842e2..ce6b309 100644 --- a/docs/native-ui.md +++ b/docs/native-ui.md @@ -22,6 +22,19 @@ NativeUiService.RequestReview(); NativeUiService.Share(text: "Check out my high score!", url: "https://example.com/game"); ``` +## Async alerts (preferred) + +```csharp +int selectedButton = await NativeUiService.ShowAlertPopUpAsync( + isAlertSheet: false, + isDismissible: false, + title: "Connection Lost", + message: "Please check your connection and try again.", + new AlertButton { Text = "Reconnect", Style = AlertButtonStyle.Default }); +``` + +The returned `Awaitable` completes on Unity's main thread with the selected zero-based button index. Await it exactly once because Unity pools `Awaitable` instances. Existing `AlertButton.Callback` actions still run before completion; omit them when the caller handles the returned index. Programmatic dismissal or replacement cancels the pending await. + ## Instance API For mock-friendly consumer code: @@ -46,7 +59,7 @@ public enum AlertButtonStyle Alerts accept one to three buttons. Labels and styles must each be unique within an alert: iOS matches callbacks by button text, while Android maps the three styles onto its three native button slots. -Call `ShowAlertPopUp` from Unity's main thread. Every `AlertButton.Callback` returns to the synchronization context captured by that call before it invokes consumer code, including Android callbacks originating on the OS UI thread. +Call alert APIs from Unity's main thread. Legacy `AlertButton.Callback` actions switch through `Awaitable.MainThreadAsync` before invoking consumer code, including Android callbacks originating on the OS UI thread. The overload without `isDismissible` preserves the original dismissible behavior. Set `isDismissible: false` for blocking alerts that must remain until the user selects a button; non-dismissible action sheets are rejected because iOS action sheets can be dismissed outside the sheet. From 662b4c25584d929c468b08cc3cef5651665ee634 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 14 Aug 2026 01:03:36 +0300 Subject: [PATCH 3/3] chore: prepare 1.2.0 release --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd9ec4..f3fb22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0] - 2026-08-14 **New**: - Added `ShowAlertPopUpAsync`, which returns the selected button index through Unity's `Awaitable` API. diff --git a/package.json b/package.json index a340031..2b83fc2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "com.gamelovers.mobileservices", "displayName": "Mobile Services", "author": "Miguel Tomas", - "version": "1.1.0", + "version": "1.2.0", "unity": "6000.0", "license": "MIT", "description": "Mobile platform services: local notifications, native UI, haptics, permissions, ATT, deep links, gestures, device helpers, and build/simulator tooling.",