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.",