Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```
Expand Down Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions Runtime/NativeUi/INativeUiService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using UnityEngine;

// ReSharper disable once CheckNamespace
namespace GameLovers.MobileServices.NativeUi
{
Expand All @@ -20,6 +22,14 @@ void ShowAlertPopUp(
string message,
params AlertButton[] buttons);

/// <inheritdoc cref="NativeUiService.ShowAlertPopUpAsync(bool,bool,string,string,AlertButton[])"/>
Awaitable<int> ShowAlertPopUpAsync(
bool isAlertSheet,
bool isDismissible,
string title,
string message,
params AlertButton[] buttons);

/// <inheritdoc cref="NativeUiService.DismissAlertPopUp"/>
void DismissAlertPopUp();

Expand Down Expand Up @@ -52,6 +62,15 @@ public void ShowAlertPopUp(
params AlertButton[] buttons)
=> NativeUiService.ShowAlertPopUp(isAlertSheet, isDismissible, title, message, buttons);

/// <inheritdoc />
public Awaitable<int> ShowAlertPopUpAsync(
bool isAlertSheet,
bool isDismissible,
string title,
string message,
params AlertButton[] buttons)
=> NativeUiService.ShowAlertPopUpAsync(isAlertSheet, isDismissible, title, message, buttons);

/// <inheritdoc />
public void DismissAlertPopUp() => NativeUiService.DismissAlertPopUp();

Expand Down
218 changes: 183 additions & 35 deletions Runtime/NativeUi/NativeUiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public struct AlertButton
/// </summary>
public static class NativeUiService
{
private static readonly object _alertCompletionLock = new object();
private static AwaitableCompletionSource<int> _currentAlertCompletion;

#if UNITY_IOS
/// <summary>Native iOS callback signature; the button is identified by its text.</summary>
internal delegate void AlertButtonDelegate(string buttonText);
Expand Down Expand Up @@ -68,7 +71,8 @@ public static void ShowAlertPopUp(bool isAlertSheet, string title, string messag
/// <remarks>
/// 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.
/// </remarks>
public static void ShowAlertPopUp(
bool isAlertSheet,
Expand All @@ -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
/// <summary>
/// Shows an alert and completes with the selected button index on Unity's main thread.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="isAlertSheet">Whether iOS presents an action sheet instead of an alert.</param>
/// <param name="isDismissible">Whether the alert can close without selecting a button.</param>
/// <param name="title">Alert title.</param>
/// <param name="message">Alert message.</param>
/// <param name="buttons">Ordered alert actions.</param>
/// <returns>An awaitable containing the selected zero-based button index.</returns>
public static Awaitable<int> ShowAlertPopUpAsync(
bool isAlertSheet,
bool isDismissible,
string title,
string message,
params AlertButton[] buttons)
{
ValidateAlert(isAlertSheet, isDismissible, buttons);
return BeginAlert(isAlertSheet, isDismissible, title, message, buttons);
}

/// <summary>
Expand Down Expand Up @@ -151,6 +145,7 @@ public static void ShowToastMessage(string message, bool isLongDuration)
/// </summary>
public static void DismissAlertPopUp()
{
CancelCurrentAlert();
#if UNITY_EDITOR
EditorDismissAlertOverride?.Invoke();
#elif UNITY_IOS
Expand Down Expand Up @@ -216,6 +211,159 @@ public static void Share(string text, string url = null, string imagePath = null
#endif
}

private static AlertButton[] BuildSelectionButtons(AlertButton[] buttons, Action<int> 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<int> BeginAlert(
bool isAlertSheet,
bool isDismissible,
string title,
string message,
AlertButton[] buttons)
{
var completionSource = new AwaitableCompletionSource<int>();
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<int> CompleteAlertAsync(
AwaitableCompletionSource<int> 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<int> alert)
{
try
{
await alert;
}
catch (OperationCanceledException)
{
}
}

private static void ReplaceCurrentAlert(AwaitableCompletionSource<int> completionSource)
{
AwaitableCompletionSource<int> previous;
lock (_alertCompletionLock)
{
previous = _currentAlertCompletion;
_currentAlertCompletion = completionSource;
}

previous?.TrySetCanceled();
}

private static void CompleteAlert(AwaitableCompletionSource<int> completionSource, int selectedIndex)
{
lock (_alertCompletionLock)
{
if (_currentAlertCompletion != completionSource)
{
return;
}

_currentAlertCompletion = null;
}

completionSource.TrySetResult(selectedIndex);
}

private static void ClearCurrentAlert(AwaitableCompletionSource<int> completionSource)
{
lock (_alertCompletionLock)
{
if (_currentAlertCompletion == completionSource)
{
_currentAlertCompletion = null;
}
}
}

private static void CancelCurrentAlert()
{
AwaitableCompletionSource<int> completionSource;
lock (_alertCompletionLock)
{
completionSource = _currentAlertCompletion;
_currentAlertCompletion = null;
}

completionSource?.TrySetCanceled();
}

private static void ValidateAlert(bool isAlertSheet, bool isDismissible, AlertButton[] buttons)
{
if (!isDismissible && isAlertSheet)
Expand Down
2 changes: 2 additions & 0 deletions Samples~/MobileServicesSamples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>`.

## Haptics

The Haptics view exposes every preset—`Selection`, `Success`, `Warning`, `Error`, and the five impact presets—with three duration modes:
Expand Down
20 changes: 20 additions & 0 deletions Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void Init()
[TearDown]
public void Cleanup()
{
NativeUiService.DismissAlertPopUp();
NativeUiService.EditorShowAlertOverride = _showAlertOverride;
NativeUiService.EditorDismissAlertOverride = _dismissAlertOverride;
}
Expand All @@ -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<int> 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
Expand Down
Loading
Loading