diff --git a/AGENTS.md b/AGENTS.md index 2cef1fb..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. +- 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 abe4457..f3fb22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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). +## [1.2.0] - 2026-08-14 + +**New**: +- Added `ShowAlertPopUpAsync`, which returns the selected button index through Unity's `Awaitable` API. + +**Fixed**: +- Alert button callbacks now return through `Awaitable.MainThreadAsync` before invoking consumer code. + ## [1.1.0] - 2026-08-13 **New**: diff --git a/README.md b/README.md index 4a57c01..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. 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 a8b5c45..6167be0 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -33,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); @@ -68,7 +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. + /// and Android. Call this API from Unity's main thread; button callbacks return there before + /// invocation. /// public static void ShowAlertPopUp( bool isAlertSheet, @@ -78,41 +82,31 @@ public static void ShowAlertPopUp( params AlertButton[] buttons) { ValidateAlert(isAlertSheet, isDismissible, buttons); + ObserveLegacyAlert(BeginAlert(isAlertSheet, isDismissible, title, message, 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 + /// + /// 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); } /// @@ -151,6 +145,7 @@ public static void ShowToastMessage(string message, bool isLongDuration) /// public static void DismissAlertPopUp() { + CancelCurrentAlert(); #if UNITY_EDITOR EditorDismissAlertOverride?.Invoke(); #elif UNITY_IOS @@ -216,6 +211,159 @@ public static void Share(string text, string url = null, string imagePath = null #endif } + 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) + { + AwaitableCompletionSource previous; + lock (_alertCompletionLock) + { + previous = _currentAlertCompletion; + _currentAlertCompletion = completionSource; + } + + previous?.TrySetCanceled(); + } + + private static void CompleteAlert(AwaitableCompletionSource completionSource, int selectedIndex) + { + lock (_alertCompletionLock) + { + if (_currentAlertCompletion != completionSource) + { + return; + } + + _currentAlertCompletion = null; + } + + completionSource.TrySetResult(selectedIndex); + } + + private static void ClearCurrentAlert(AwaitableCompletionSource completionSource) + { + lock (_alertCompletionLock) + { + if (_currentAlertCompletion == completionSource) + { + _currentAlertCompletion = null; + } + } + } + + private static void CancelCurrentAlert() + { + AwaitableCompletionSource completionSource; + lock (_alertCompletionLock) + { + completionSource = _currentAlertCompletion; + _currentAlertCompletion = null; + } + + completionSource?.TrySetCanceled(); + } + private static void ValidateAlert(bool isAlertSheet, bool isDismissible, AlertButton[] buttons) { if (!isDismissible && isAlertSheet) 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 f75ef9c..677ad29 100644 --- a/Tests/EditMode/Unit/NativeUiServiceTest.cs +++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs @@ -26,6 +26,7 @@ public void Init() [TearDown] public void Cleanup() { + NativeUiService.DismissAlertPopUp(); NativeUiService.EditorShowAlertOverride = _showAlertOverride; NativeUiService.EditorDismissAlertOverride = _dismissAlertOverride; } @@ -94,6 +95,26 @@ public void DismissAlertPopUp_EditorOverride_InvokesCallback() Assert.AreEqual(1, callbackCount); } + [Test] + // 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() + { + 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] // 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). 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 9acb814..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,6 +59,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 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. `DismissAlertPopUp()` closes the active alert without invoking an action. Showing a new alert replaces the current one. 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.",