diff --git a/AGENTS.md b/AGENTS.md index 969fca8..2cef1fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,222 +1,57 @@ -# GameLovers.MobileServices - AI Agent Guide - -> **Companion files**: `CLAUDE.md` wraps this file for Claude Code — edit `AGENTS.md`, not `CLAUDE.md`. `README.md` is the user-facing entry point. - -## 1. Package Overview -- **Package**: `com.gamelovers.mobileservices` -- **Unity**: Unity 6 only. Supported streams are 6000.5.x, 6000.3.x, and 6000.0.x. Validation editors are 6000.5.7f1 (primary), 6000.3.21f1, and 6000.0.81f1; other streams are unsupported/untested. -- **Dependencies** (see `package.json`) - - `com.unity.mobile.notifications` (**2.3.0**) - - `com.unity.inputsystem` (**1.11.0**) -- **Input backend**: consumers must use **Input System Package (New)** or **Both** for Player Settings > Active Input Handling. InputForUI's Input System provider is gated by `ENABLE_INPUT_SYSTEM`; legacy-only projects do not satisfy this package's gesture/input runtime contract. - -This package consolidates mobile-specific platform services: -- **Native UI**: alerts (modal + action sheet), toast-style messages, OS rating prompt (`RequestReview`), and share sheet (`Share`). Static `NativeUiService` plus an instance-based `INativeUiService` / `NativeUiServiceInstance` wrapper for mockable consumer code. -- **Notifications**: platform wrapper over Unity Mobile Notifications (Android/iOS) with a fluent `service.Schedule().In(...).Title(...).Send()` builder (`NotificationBuilder`). -- **Gestures**: Input System–based pointer abstraction + swipe/tap detection. -- **Haptics**: zero-dependency haptic feedback with 9 presets, custom intensity, time-bounded looping. Built directly on iOS `UI*FeedbackGenerator` + Android `VibrationEffect.createWaveform` — no NiceVibrations or other third-party plugin. -- **Device**: `IDeviceService` umbrella facade over 7 sub-services — `SafeArea`, `ScreenWake`, `Battery` (with iOS / Android low-power-mode awareness), `AudioSession` (iOS silent-switch override), `Permissions` (unified iOS+Android, Task-based async, including the multi-permission `RequestAsync(params AppPermission[])` overload), `Att` (App Tracking Transparency, no `com.unity.ads.ios-support` dep), `DeepLink` (with cold-start link queueing) — plus an `IDeepLinkRouter` layered on `IDeepLinkService` for path-pattern routing. -- **`IMobileService`** umbrella facade aggregating `NativeUi` / `Notifications` / `Haptics` / `Device` behind a single DI registration. - -For user-facing docs, treat `README.md` as the primary entry point — it's the lean overview. Deeper per-subsystem API reference lives in [`docs/`](docs/) (`docs/README.md` is the index). This file is for contributors/agents working on the package itself. - -## 2. Runtime Architecture (high level) - -### Native UI (`GameLovers.MobileServices.NativeUi`) -- **Main entry point**: `Runtime/NativeUi/NativeUiService.cs` (`NativeUiService` is `static`) - - Android: uses `AndroidJavaClass` + `AndroidJavaObject` to build an `android.app.AlertDialog` / `android.widget.Toast` / `Intent.ACTION_SEND`, and uses `com.google.android.play.core.review.ReviewManager` for in-app review. - - iOS: uses `[DllImport("__Internal")]` native functions implemented in `Plugins/iOS/NativeUi.m`. **All four exports are `_GameLovers`-prefixed** — `_GameLoversAlertMessage`, `_GameLoversToastMessage`, `_GameLoversRequestReview`, `_GameLoversShare` — to avoid duplicate-symbol linker collisions with other native plugins (the alert/toast pair were unprefixed before; the C# `[DllImport]` declarations carry matching `EntryPoint =` attributes so the C# method names are unchanged). -- **Button model**: `AlertButton` + `AlertButtonStyle { Default, Destructive, Cancel }` (iOS-native vocabulary; renamed from `Positive/Negative` during the 1.0.0 modernization). -- **Review prompt**: `RequestReview()` — iOS `SKStoreReviewController` (modern `requestReviewInScene:` on iOS 14+, fallback to `requestReview` on iOS 10.3–13); Android Play Core `ReviewManagerFactory` + `launchReviewFlow`. The OS throttles the actual prompt frequency; **fire-and-forget with no "was shown" signal** (the OS may silently suppress it under quota — normal, not an error). There is **no store-URL fallback**. Because there is no success callback, it logs when the prompt is requested (iOS) / when the Play flow is launched (Android); when Android's Play flow cannot run (Play Core missing / unsuccessful request task / launch exception) it logs a warning / error. The editor branch mirrors this — it logs and (when the Device Simulator is engaged via `EditorRequestReviewOverride`) paints the mock; otherwise it logs the editor no-op. -- **Share sheet**: `Share(text, url, imagePath, title)` — iOS `UIActivityViewController`; Android `Intent.ACTION_SEND` via `Intent.createChooser`. Image+text share works on both. iPad popover anchors to the view centre with no arrow. -- **Android Play Core dependency**: `RequestReview()` needs `com.google.android.play:review` on the Android classpath. The build postprocessor auto-injects it by default via `IPostGenerateGradleAndroidProject` into the generated Gradle project, so consumers need no `mainTemplate.gradle` editing. Without the dependency (opted out and not added) the call logs an error and returns; it never throws. - -### Notifications (`GameLovers.MobileServices.Notifications`) -- **Public API**: `Runtime/Notifications/MobileNotificationService.cs` - - Interface: `INotificationService` - - Concrete: `MobileNotificationService` -- **Host / lifecycle**: `Runtime/Notifications/GameNotificationsMonoBehaviour.cs` - - Owns the active platform implementation (`IGameNotificationsPlatform`). - - Handles queueing/scheduling behavior based on `OperatingMode`. - - Persists scheduled notifications on background using `PlayerPrefs` (key: `"notifications"`). -- **Platform implementations** - - Android: `Runtime/Notifications/Android/AndroidNotificationsPlatform.cs` + `AndroidGameNotification.cs` - - iOS: `Runtime/Notifications/iOS/iOSNotificationsPlatform.cs` + `iOSGameNotification.cs` - - Editor fallback: `Runtime/Notifications/Internal/EditorGameNotification.cs` -- **Editor simulator bridge**: `Editor/Simulation/MobileNotificationSimulation.cs` exposes a transient target contract. The Notifications Scheduler sample registers its own service from `Samples~/MobileServicesSamples/Editor/Simulation/`; the Device Simulator can explicitly deliver the next pending item or deliver due items during its poll. Without a registered target, the panel remains a generic banner preview. This bridge is editor-only and absent from player builds. -- **Notification shape**: `Runtime/Notifications/IGameNotification.cs` - - Cross-platform surface; internally mapped to Unity Mobile Notifications types. -- **Channels** - - Wrapper: `Runtime/Notifications/GameNotificationChannel.cs` - - Android requires at least one channel to be registered; the first channel passed becomes the platform default (`AndroidNotificationsPlatform.DefaultChannelId`). - -### Device (`GameLovers.MobileServices.Device`) -This namespace holds the umbrella facade plus every device-touching service. All sub-services live in the same namespace; sub-folders under `Runtime/Device/` (`Audio/`, `State/`, `Internal/`, `Permissions/`, `Tracking/`, `DeepLinks/`) are organizational only and do NOT add namespace nesting (same convention `Runtime/Notifications/` already uses with its `Android/`, `iOS/`, `Internal/` sub-folders). - -- **Umbrella facade**: `Runtime/Device/IDeviceService.cs` + `DeviceService.cs`. Constructs each child internally for the default case; an injection constructor accepts mocks for tests. `Dispose()` propagates to children that implement `IDisposable`. -- **Shared host**: `Runtime/Device/Internal/DeviceServicesHost.cs` — internal `MonoBehaviour`, `DontDestroyOnLoad`, lazily spawned. Exposes `RegisterLateUpdate` / `RegisterSecondTick` / `RegisterFocusChanged` / `RegisterIosLowPowerModeChanged`. Means the runtime cost of the entire Device subsystem is a single GameObject. -- **Audio Session**: `Runtime/Device/Audio/IIosAudioSessionService.cs` + `IosAudioSessionService.cs`. `ConfigureForPlayback()` sets `AVAudioSessionCategoryPlayback` + `setActive:YES` via `Plugins/iOS/iOSAudioSession.m`. Android / Editor / unsupported platforms are safe no-ops. Instance (not static) so it can sit on `IDeviceService.AudioSession`. -- **Safe Area**: `Runtime/Device/State/ISafeAreaService.cs` + `SafeAreaService.cs`. Polls `Screen.safeArea` in `LateUpdate` via the host; fires `OnSafeAreaChanged` on diff. Companion `SafeAreaContainer` UI Toolkit `VisualElement` self-pads to the safe area; can be constructed with the service or wired up via `SetSafeAreaService` for UXML usage. -- **Screen Wake**: `Runtime/Device/State/IScreenWakeService.cs` + `ScreenWakeService.cs`. Trivial wrapper over `Screen.sleepTimeout`; idempotent. -- **Battery**: `Runtime/Device/State/IBatteryService.cs` + `BatteryService.cs`. Polls `SystemInfo.batteryLevel` / `batteryStatus` once per second via the host; fires `OnLevelChanged` (≥1% diff), `OnStatusChanged`, `OnLowPowerModeChanged`. iOS LPM via `Plugins/iOS/Battery.m` exposing `_GameLoversBatteryIsLowPowerModeEnabled` plus an `NSProcessInfoPowerStateDidChangeNotification` observer that calls back via `UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", "")`. Android LPM polled via JNI `PowerManager.isPowerSaveMode()` on focus change. -- **Permissions**: `Runtime/Device/Permissions/IPermissionsService.cs` + `PermissionsService.cs`. `Check(...)` is sync, `RequestAsync(...)` returns `Task` (no UniTask dep). Android uses `UnityEngine.Android.Permission` with manifest mapping for Camera/Mic/FineLocation; uses `READ_MEDIA_IMAGES` (API 33+) for Photos and `POST_NOTIFICATIONS` for Notifications. iOS uses `Plugins/iOS/Permissions.m` with one bridge per permission (`AVCaptureDevice` for Camera/Mic, `CLLocationManager` for Location, `PHPhotoLibrary` for Photos, `UNUserNotificationCenter` for Notifications). Async results returned via `UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", ":")` to `Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs` which resolves the matching `TaskCompletionSource`. - - **Location delegate lifetime**: iOS bridge keeps `CLLocationManager` instances alive in a static `NSMutableArray` so the delegate isn't GC'd before `locationManagerDidChangeAuthorization:` fires. The delegate clears itself from the manager after dispatch. -- **App Tracking Transparency**: `Runtime/Device/Tracking/IAttService.cs` + `AttService.cs`. iOS bridge: `Plugins/iOS/Att.m` calling `ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler:` (iOS 14+ only — pre-14 returns Authorized). Same `UnitySendMessage` callback pattern as Permissions but with a separate `AttCallbackReceiver` MonoBehaviour to keep payload formats per-subsystem. **No dependency on `com.unity.ads.ios-support`** — explicit goal. -- **Deep Links**: `Runtime/Device/DeepLinks/IDeepLinkService.cs` + `DeepLinkService.cs`. Wraps `Application.deepLinkActivated`; on construction captures `Application.absoluteURL` (set by Unity before any subscriber attaches when the app is cold-launched with a link) and replays it to the first subscriber via the `OnLinkActivated` event's `add` accessor. Runtime delivery clears any pending cold-start link. -- **Deep Link Router**: `Runtime/Device/DeepLinks/IDeepLinkRouter.cs` + `DeepLinkRouter.cs`. Layered over `IDeepLinkService`. Path-pattern routing: literal segments match exactly (case-insensitive), `:name` segments capture into a params dict (e.g. `/promo/:id` → `{ "id": "spring2026" }`). First match wins, registration order is preserved. Router subscribes once at construction; consumers hold the router for the lifetime of the app. - -### Gestures (`GameLovers.MobileServices.Gestures`) -- **Input source**: Unity's `EnhancedTouch` API (`Touch.onFingerDown/Move/Up`) -- **Gesture detection** - - `Runtime/Gestures/GestureController.cs` subscribes to EnhancedTouch finger events and emits gesture events (`Pressed`, `PotentiallySwiped`, `Swiped`, `Tapped`). - - `Runtime/Gestures/ActiveGesture.cs` is the internal state accumulator per finger. - - `Runtime/Gestures/SwipeInput.cs` is the public data structure for swipe output. - - `Runtime/Gestures/TapInput.cs` is the public data structure for tap output. - -### Haptics (`GameLovers.MobileServices.Haptics`) -- **Public API**: `Runtime/Haptics/IHapticsService.cs` + `Runtime/Haptics/HapticsService.cs` - - `Enabled`, `IsSupported`, `IsPlaying` - - `PlayPreset(HapticPreset)` — natural one-shot; sugar for `PlayPresetDuration(preset, 0f)` - - `PlayPresetDuration(HapticPreset, float duration = -1f)` — `0`=natural one-shot, `<0`=loop until `StopCurrentHaptic`, `>0`=loop with real-time auto-stop - - `PlayCustom(float intensity01, float durationMs)` — single-intensity haptic, always finite - - `StopCurrentHaptic()` — single stop entry point; idempotent -- **Preset catalogue**: `Runtime/Haptics/HapticPreset.cs` — 9 entries (Selection, Success, Warning, Error, ImpactLight, ImpactMedium, ImpactHeavy, ImpactRigid, ImpactSoft) plus `None`. -- **Backend abstraction**: `Runtime/Haptics/Internal/IHapticsBackend.cs` selects platform impl at construction: - - iOS: `IosHapticsBackend` → `[DllImport("__Internal")]` into `Plugins/iOS/Haptics.m` (UIKit `UIImpactFeedbackGenerator` / `UINotificationFeedbackGenerator` / `UISelectionFeedbackGenerator`); looping via `NSTimer`. - - Android: `AndroidHapticsBackend` → pure JNI to `android.os.Vibrator.vibrate(VibrationEffect)`. `VibrationEffect.createWaveform(long[] timings, int[] amplitudes, int repeat)` for presets; `repeat=0` loops, `cancel()` stops. Requires API 26 (Android 8.0)+. - - Editor: `EditorHapticsBackend` (logs). - - Other: `NoOpHapticsBackend`. -- **Auto-stop**: `Runtime/Haptics/Internal/HapticsHost.cs` (internal MonoBehaviour, lazily spawned on first play, `DontDestroyOnLoad`) runs a single `WaitForSecondsRealtime` coroutine. Each new `Play*` cancels the previous coroutine — only one auto-stop is ever pending. No `ICoroutineService` dependency on `com.gamelovers.services`. -- **Lofelt/NiceVibrations**: `**zero runtime dependency**`. Lofelt code in the demons project was used as inspiration for preset envelope shapes only; every line in this package is original. - -### Editor (`GameLovers.MobileServices.Editor`) -- **Assembly**: `Editor/GameLovers.MobileServices.Editor.asmdef` (`includePlatforms: ["Editor"]`). References the runtime asmdef and the Unity Input System / Notifications packages. -- **Single editor surface — the Device Simulator plugin** (`Editor/Explorer/DeviceSimulatorPanel/`): there is no standalone Explorer or Simulator window anymore (both removed during the 1.0.0 consolidation — the controller-in-one-window / canvas-in-another workflow and the split `WindowPlatform`/`OverlayPlatform` state were the UX problem). `MobileServicesDeviceSimulatorPlugin` is the one place to drive mocks, read live diagnostics, and view the haptic envelope graph; the in-Game-view overlay is the one canvas. - - **`MobileServicesDeviceSimulatorPlugin`** — `UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` subclass auto-discovered by Unity (no menu item, no registration), embedded in the Device Simulator window's Control Panel. Holds only `PermissionsService` + `AttService` instances (for reading state into the dropdowns); a single 500 ms `root.schedule.Execute(...)` poll auto-syncs the platform skin from the device profile (reads `Application.platform`, which the Device Simulator spoofs — robust across Unity 6 minor versions where `DeviceSimulator.deviceChanged` varies) and syncs the state dropdowns. Foldouts: Native UI, Haptics, Notifications (generic heads-up preview plus a connected Notifications Scheduler target), Gestures, Permissions, ATT. (There is no Device state foldout, and no App Review foldout — see below.) **Editor-dead controls are deliberately omitted**: because the panel is editor-only, any control whose runtime service runs its `#if UNITY_EDITOR` stub with no observable effect was cut — Haptics has NO play/loop/stop/custom (the `EditorHapticsBackend` only logs; the preset buttons exist solely to plot the envelope). The envelope is an **intensity-over-time curve** rendered via `Painter2D` (`generateVisualContent` → step waveform + filled area) with X=time(ms) / Y=intensity(0–1) axis ticks — see `PaintEnvelope` / `BuildEnvelopeGraph`. **Permissions and ATT model the real OS lifecycle**: each permission's `PermissionStatus` dropdown (and ATT's one `AttStatus` dropdown) is the **Settings surface** — it writes an `EditorPrefs`-backed simulated-decision store via `EditorPlatformSimulator.SetPermissionState` / `SetAttState` (changing it mirrors the user toggling the permission in OS Settings; `NotDetermined` re-arms the first-time prompt). While the panel is open and the master switch is on it calls `EditorPlatformSimulator.Engage()` (from `ApplyEnabledState`; `Disengage()` on `OnDestroy` / switch-off), which installs the editor overrides so `Check()` / `CurrentStatus` read the store and the first `RequestAsync()` / `RequestAuthorizationAsync()` on a `NotDetermined` entry pushes the prompt mock through `MobileSimulatorState.PushPermissionDialog` (usage description pulled from `MobileServicesSettings`) and resolves the returned `Task` when the user answers — afterwards the decision is cached and the prompt never re-shows (matching the OS). ATT prompts only under the iOS skin (Android / other skins return `Authorized`, mirroring `AttService`). The dropdowns are kept in sync with the effective `Check()` / `CurrentStatus` by the 500 ms poll via `SetValueWithoutNotify`. Because the store is `EditorPrefs` (survives the Play domain reload), the **state dropdowns + reset buttons are NOT play-mode-gated** — only the per-section **Allow / Don't Allow** pending-prompt fallback buttons are (a prompt only pends when the game actually requests at runtime; the fallback exists because overlay clicks are unreliable in the edit-mode Game view). There is no longer a per-section "Enter Play mode" banner (`MakeSectionPlayModeBanner` was removed); only the global top `BuildPlayModeBanner` remains, covering the gestures + pending-prompt controls. **There is no Device state foldout**: its only control had been a connectivity state dropdown, and `ConnectivityService` was removed from the package entirely (it was a thin wrapper over `Application.internetReachability` whose only value was a change event; consumers poll `Application.internetReachability` directly). Battery + LPM and notch/safe-area had already been dropped before that (desktop-junk `SystemInfo` / no in-panel visual; Unity's Device Simulator + `EditorPlatformSimulator.SetSafeArea` cover them). **Gestures** auto-spawns a hidden `[EditorOnly]` `GestureController` + enables `EnhancedTouch.TouchSimulation` in play mode when the scene has none (torn down on play-exit / panel-close), so it needs zero scene setup; it prefers a user's scene controller if present. **Notification banner mock** (`MockBuilders.BuildNotificationBanner` + the `mock-notif-*` USS) renders a realistic heads-up: app-icon + app-name/time header + bold title + body, light card on iOS, white card with a small colored icon + colored app name on Android. **There is no Deep links foldout**: `DeepLinkService.SimulateLinkActivated` is instance-scoped (no static override like Permissions/ATT use), so the panel could only fire into a throwaway instance it owns, never the game's — deep links are driven from the `DeepLinkRouter` sample or `EditorPlatformSimulator.SimulateDeepLink(uri, service)` instead. **Notifications scheduling is not owned by the panel** (the sample's `MobileNotificationService` remains the source of truth); when the `NotificationsScheduler` sample is active, its editor adapter exposes explicit pending delivery and due-time polling, while no active target leaves the generic edit-mode heads-up banner **preview**. What remains earns its place via a mock render, the envelope graph, or an `EditorPlatformSimulator` override (set permission/ATT state, drives your code in play/tests). The play-mode-gated controls (Permission / ATT state dropdowns) need Play mode; the mock previews + envelope graph render in edit mode. `OnCreate`/`OnDestroy` call `MobileSimulatorRuntimeOverlay.NotifyPluginActive(true/false)` so the overlay is alive exactly while the panel is open. **Master switch**: an `Editor Simulator` toggle in the header binds to `MobileSimulatorState.Enabled`; the header stays interactive while every section below it (wrapped in `_sectionsContainer`) is enabled/disabled as a group via `SetEnabled` (composes hierarchically with the existing play-mode gating). Turning it off broadcasts `PushDismissAll` to clear any visible mock and hides the Game-view `[EDITOR SIMULATOR]` banner. **App Review (NOT a foldout)**: review is neither a Native UI button nor its own foldout. It is stateless and fire-and-forget, so there is nothing to configure and no manual trigger worth exposing — an info-only foldout would not earn its place (same "editor-dead controls are omitted" bar applied to Haptics' missing play/stop). It follows the **shown-when-requested** pattern purely through the game's own code: when the consumer calls `NativeUiService.RequestReview()` (play mode) while the simulator is engaged, the editor-only `NativeUiService.EditorRequestReviewOverride` hook → `MobileSimulatorState.PushReview()` → the overlay paints the per-platform mock (iOS StoreKit centered star sheet titled with `Application.productName`; Android Play bottom sheet). The mock's own buttons dismiss it (play-mode pointer input is reliable, and review never renders in the edit-mode Game view since there is no panel trigger); the global `Dismiss all UIs` remains as an edge-case escape hatch. Fire-and-forget — no resolve/await (the OS gives no success callback), so unlike Permissions/ATT there is no pending-prompt fallback row. **Dismissal**: there is no global header dismiss button — `Dismiss all UIs` lives in the Native UI foldout and `Dismiss Banner` in the Notifications foldout (both broadcast `PushDismissAll`, clearing the single overlay stage). App review has no panel control at all (no foldout): the review mock only renders on a real runtime `RequestReview()` call (play mode), where its own buttons close it; `Dismiss all UIs` covers the edge case. - - **`MobileSimulatorState`** (`Editor/Explorer/Overlays/`) — singleton broker / event bus. One renderer surface now, so `Push*` calls are plain broadcasts (no `SimulatorTarget`) and a single `Platform` skin (no `Window`/`Overlay` split). `MockBuilders` provides per-shape factory methods; the three USS files (`MobileSimulator.Common.uss`, `MobileSimulator.iOS.uss`, `MobileSimulator.Android.uss`) are swapped when the platform flips. An `[EDITOR SIMULATOR]` watermark is shown in the Game view while the simulator is enabled (the master switch — `MobileSimulatorState.Enabled`, persisted to `EditorPrefs` with an `EnabledChanged` event; the overlay hides the watermark when off). - - **`MobileSimulatorRuntimeOverlay`** (`Editor/Explorer/Overlays/`) — editor-only `[InitializeOnLoad]` bootstrap. Spawns a `[EditorOnly] MobileSimulatorOverlay` GameObject with a UIDocument + programmatic `PanelSettings` (`sortingOrder = short.MaxValue`) rendering pixel-aligned with the simulated device's `Screen.*` values. A single idempotent `RefreshLifecycle()` keeps the overlay alive while `_pluginActive` (Device Simulator panel open, **edit OR play mode** — `UIDocument` is `[ExecuteAlways]`); a `playModeStateChanged` handler re-evaluates across play transitions. Interaction inside the mock is unreliable in the edit-mode Game view, so dismissal is driven from the plugin's per-section dismiss buttons (`Dismiss all UIs` in Native UI, `Dismiss Banner` in Notifications — both broadcast `PushDismissAll`, which clears the single overlay stage); the overlay is display-only. `DestroyStaleHosts()` de-dups after a domain reload; `DontDestroyOnLoad` is only called in play mode (avoids an edit-mode warning). -- **`EditorPlatformSimulator`** (`Editor/Simulation/EditorPlatformSimulator.cs`, namespace `GameLovers.MobileServices.Editor.Simulation`): static editor-only façade exposing `Engage` / `Disengage` (install/remove the OS-faithful Permission + ATT overrides **and the fire-and-forget review hook** `NativeUiService.EditorRequestReviewOverride = () => MobileSimulatorState.PushReview()`), `SetIosLowPowerMode`, `SetSafeArea` / `ClearSafeAreaOverride`, `SimulateDeepLink`, `SetPermissionState` / `GetPermissionState` / `ResetAllPermissions` / `HasPendingPermissionPrompt` / `ResolvePendingPermissionPrompt`, `SetAttState` / `GetAttState` / `ResetAtt` / `HasPendingAttPrompt` / `ResolvePendingAttPrompt`, `DismissAllOverlays`. The Permission/ATT decision store is `EditorPrefs`-backed (keys `GameLovers.MobileServicesSimulator.Perm.` / `…Att`), defaults to `NotDetermined`, and survives editor restarts. Drives runtime services via the `internal` editor hooks documented under §2 below. -- **Editor-only runtime hooks** (consumed only when `UNITY_EDITOR`): `BatteryService.EditorLowPowerModeOverride` + `SimulateLowPowerModeChanged()`, `SafeAreaService.EditorSafeAreaOverride` + `SimulateSafeAreaChanged()`, `DeepLinkService.SimulateLinkActivated(Uri)`, `PermissionsService.EditorCheckOverride` / `EditorRequestOverride` / `EditorRequestAsyncOverride`, `AttService.EditorCurrentStatusOverride` / `EditorRequestResultOverride` / `EditorRequestAsyncOverride`, `NativeUiService.EditorRequestReviewOverride` (an `Action`; fire-and-forget — invoked from the editor branch of `RequestReview` when set, else the plain `Debug.Log` no-op runs). The `EditorRequestAsyncOverride` hooks (a `Func<…, Task<…>>`) let the simulator return a `Task` that completes when the user answers a prompt, and take precedence over the synchronous request override; with no override installed the editor still short-circuits to `Granted` / `Authorized`. All gated behind `#if UNITY_EDITOR` so player builds carry none of this surface. -- **Internal introspection accessors on runtime services** (not part of the public surface; visible to the Editor asm via `InternalsVisibleTo` on `Runtime/AssemblyInfo.cs`): `HapticsService.CurrentPreset` / `CurrentDurationSeconds` / `Backend`; `MobileNotificationService.CurrentMode` / `Channels`; `PermissionsService.CheckSnapshot()`. Add similar `internal` accessors for any new service surfaced in the Device Simulator panel rather than widening public API. -- **Centralised haptic envelopes** (`Runtime/Haptics/Internal/HapticEnvelopes.cs`): the per-preset `(timings, amplitudes)` tables that previously lived only inside the `UNITY_ANDROID && !UNITY_EDITOR` block of `AndroidHapticsBackend` now live in this always-compiled internal class. The Android backend and the Device Simulator panel's envelope graph both read from it — single source of truth. -- **`MobileServicesConfig`** (`Editor/Settings/MobileServicesConfig.cs`): an **editor-only `ScriptableObject` asset** (NOT a `ScriptableSingleton` / `ProjectSettings` — moved in the Unreleased cycle for normal-dev-UX Inspector editing and to match the GameLovers `UiConfigs` / `GoogleSheetImporter` config pattern). A single instance is located ANYWHERE in the project via `MobileServicesConfig.Instance` (cached `AssetDatabase.FindAssets("t:MobileServicesConfig")`; returns a transient in-memory default when no asset exists so build/test reads never NRE) and created/selected via `MobileServicesConfig.GetOrCreateAsset()`. Because the type lives in the Editor assembly the asset is editor-only — `GetOrCreateAsset` defaults it under `Assets/Editor/`; it must stay under an `Editor/` folder so it never ships. Holds per-permission iOS usage descriptions (**per-locale `LocaleEntry` rows — English is the base written to the Info.plist root; every other locale is emitted as `.lproj/InfoPlist.strings`**), ATT usage description (also per-locale), capability toggles, Android manifest opt-ins, the Android `IncludePlayReviewDependency` (default ON) + editable `PlayReviewDependencyCoordinate` (default `com.google.android.play:review:2.0.2`), and the `ManageNativeBuildManually` escape (the single switch that makes the package perform no native-build configuration + skip the fail-fast iOS validation). Setters call `Persist()` (`EditorUtility.SetDirty` + `AssetDatabase.SaveAssets`, no-op for the transient instance). **Removed as over-engineering / redundant**: `ScanPopulatedCapabilities` (dead), `EnableRuntimeSimulatorOverlay` (editor-tooling state, not build config), `Capabilities.BackgroundAudio` (Unity's `Player Settings > iOS > Behavior in Background = Custom > Audio` owns `UIBackgroundModes`), `AllowPlaceholderUsageDescriptions` (validation is now fail-fast; use suggested-copy or the kill-switch), and `BuildCallbackOrder` (`callbackOrder` is hardcoded `1000`). -- **Config ownership clarification**: `MobileServicesConfig.Instance` may still provide an editor-only transient convenience object, but native build callbacks never use it implicitly. `TryGetPersistedConfig` is the build boundary; duplicate persisted assets, duplicate permission/localization rows, malformed enabled capability/deep-link rows, and invalid Maven coordinates fail before any native file is read or written. `NativeDeepLinkSettings` exposes deduplicating iOS schemes and Android `(Scheme, Host, PathPrefix)` registrations; scanner/config mismatches are warnings and do not rewrite the asset. The build postprocessor owns callback order `1000`; the sample cleanup callback owns `2000`. -- **Callback-order correction**: the historical `BuildCallbackOrder` reference in the config inventory is obsolete; callback order is not a config setting. -- **`MobileServicesConfigEditor`** (`Editor/Settings/MobileServicesConfigEditor.cs`): `[CustomEditor(typeof(MobileServicesConfig))]` UIToolkit Inspector. Renders the per-locale usage-description lists via default `PropertyField`s (locale code + text, add/remove for free), plus a missing-English-key status `HelpBox`, a `Scan project for used services` button (uses `MobileServicesScanner`), a `Fill missing English descriptions with suggested copy` button, and an iOS Privacy Nutrition Label draft generator. The namespace contains `.Editor.`, so the base class is qualified `UnityEditor.Editor` (workspace namespace-collision rule). -- **`MobileServicesConfigMenuItems`** (`Editor/Settings/MobileServicesConfigMenuItems.cs`): `Tools/GameLovers/Mobile Services/Select Mobile Services Config` (priority 100) — find-or-create + select + ping, mirroring `UiConfigsMenuItems` / `GoogleSheetImporter`. -- **`MobileServicesScanner`** (`Editor/Settings/MobileServicesScanner.cs`): reflection-based scan over the project's user assemblies looking for references to runtime service types (`MobileNotificationService`, `DeepLinkService`, `IosAudioSessionService`, `IPermissionsService`/`PermissionsService`, `IAttService`/`AttService`, `NativeUiService`). Returns a `ProjectScanResult` consumed by the Settings Provider and the build postprocessor. -- **Native-build ownership implementation**: `MobileServicesBuildPostprocessor` now runs at callback order `1000`, resolves the persisted/effective config before scanning or file access, treats missing config and manual-management mode as no-ops, fails malformed explicitly enabled settings, and reports scanner mismatches as warnings. `MobileServicesBuildContext.TryGetEffectiveConfig(out ...)` returns false when neither a temporary context nor a persisted asset exists; `Push` clones the persisted config or a neutral all-native-disabled transient. The sample contributes only temporary declarative requirements for the exact ordered four-scene build and cleans that context at callback order `2000`; it owns no competing native mutator. - -### Samples (`Samples~/MobileServicesSamples/`) -- `package.json` exposes one **Mobile Services Samples** import containing four authored UI Toolkit scene-backed views. One runtime asmdef (`GameLovers.MobileServices.Samples`) and one editor asmdef (`GameLovers.MobileServices.Samples.Editor`) cover the entire bundle. The bundle has one canonical `Samples~/MobileServicesSamples/README.md`; controller/view folders do not own separate READMEs. -- `MobileServicesPlayground/` — breadth tour of every subsystem. Namespace `GameLovers.MobileServices.Samples.MobileServicesPlayground`. -- `HapticsPalette/` — designer iteration tool. Namespace `GameLovers.MobileServices.Samples.HapticsPalette`. -- `NotificationsScheduler/` — lifecycle demo. Namespace `GameLovers.MobileServices.Samples.NotificationsScheduler`. -- `DeepLinkRouter/` — `IDeepLinkRouter.MapRoute` pattern demo. Namespace `GameLovers.MobileServices.Samples.DeepLinkRouter`. -- **Sample scene policy**: each scene opens and runs without GameObject wiring. Unity 6 InputForUI routes runtime input to UI Toolkit, while shared navigation supplies the gesture bridge; the sample must not create a uGUI EventSystem. Every enabled button has palette-specific `:hover` / `:active` / `:focus` / `:disabled` states plus a deterministic pressed class; ordinary committed clicks emit one `HapticPreset.Selection`, while controls whose action is itself a haptic demonstration do not add a redundant selection pulse. The shared root arbitrates button-versus-scroll input: a short press remains a button, a committed click always clears the pressed class, and a drag past the threshold cancels the button and transfers intent to scrolling. A gesture fallback is allowed only for an input stream proven to omit required UI Toolkit events; it must not compete for pointer capture with Buttons, ScrollViews, overlays, or bottom navigation. Status cards use one sentence-case `Field: Value` per line, `Yes`/`No` booleans, and no bullet-separated fields. Routine actions report through the on-screen status/activity UI; temporary input diagnostics must be removed before handoff. -- **Sample player ownership**: `Samples~/MobileServicesSamples/Shared/` owns the four-page runtime navigation/session. Every authored scene contains the shared bottom navigation and stays independently playable; the sample editor bridge uses `EditorSceneManager.LoadSceneInPlayMode` when an unprepared Editor scene switches tabs. The session owns one `DeepLinkService`, buffers cold/warm links, and opens Links automatically. -- **Sample editor ownership**: `Samples~/MobileServicesSamples/Editor/` owns the generic `Tools > Mobile Samples Examples > Build All` / `Restore All` menu commands, a serialized `SceneAsset` catalog discovered by type (exactly one catalog, four unique pages in `MobileServicesSamplePages.All` order), Build Profile/global-scene snapshot, temporary combined build context, catalog identity verification entry point, navigation bridge, and Notifications Scheduler simulator adapter. No hand-authored scene GUID/path lookup or sample native postprocessor remains. No build controls are serialized or injected into the Game view. Deleting the imported bundle removes every sample menu and hook. -- **Combined build**: Build All installs exactly Overview, Haptics, Notifications, Links into the effective scene list and opens Unity's native Build Profiles window. Restore All uses `SessionState` to restore the exact original list during the current Unity session. The sample build preprocessor activates `MobileServicesBuildContext` only for a canonical four-scene player build; it does not modify the persisted config asset or `EditorPrefs`. -- `Samples~/MobileServicesSamples/README.md` is the sole imported bundle guide, with one anchored section per view. Adding, removing, or materially changing a view requires updates in lockstep across `package.json`, that canonical README, `docs/samples.md`, and this list. - -## 3. Layout convention - -Section §2 names every public type and the assembly it lives in. Use that plus your IDE / `find` / `Glob` for the actual inventory — the conventions below are what's load-bearing. - -- **One folder per subsystem under `Runtime/`** — `NativeUi/`, `Notifications/`, `Gestures/`, `Haptics/`, `Device/`. Each subsystem owns one C# namespace (`GameLovers.MobileServices.`). -- **Sub-folders inside a subsystem are organizational only**, NOT namespace-nesting. Examples: `Runtime/Notifications/{Android,iOS,Internal}/` and `Runtime/Device/{Audio,State,Permissions,Tracking,DeepLinks,Internal}/` all use their parent subsystem's namespace. C# enforces the namespace via the `namespace` keyword in each file, not via folder paths. -- **`Internal/` sub-folders hold non-public types** (platform backends, MonoBehaviour hosts, callback receivers, serializable DTOs). Use the `internal` access modifier; tests reach in through `Runtime/AssemblyInfo.cs` which grants `InternalsVisibleTo("GameLovers.MobileServices.{Edit,Play}Mode.Tests")` plus `GameLovers.MobileServices.Editor` for the Device Simulator panel's introspection wedge. No `Editor.Tests` grant — editor tooling is not automated-tested (see `Tests/AGENTS.md`). -- **Editor folder layout** — `Editor/Explorer/{Overlays,DeviceSimulatorPanel}/` + `Editor/Simulation/` + `Editor/Settings/` + `Editor/NativeBuild/`. Editor asmdef name is `GameLovers.MobileServices.Editor`. The simulator broker + overlay bootstrap + mock builders use the `GameLovers.MobileServices.Editor.Explorer.Overlays` namespace, the `DeviceSimulatorPlugin` lives in `GameLovers.MobileServices.Editor.Explorer.DeviceSimulatorPanel`, the simulator façade uses `GameLovers.MobileServices.Editor.Simulation`, and consumer-wide postprocessing/build context use `GameLovers.MobileServices.Editor.NativeBuild`. Sample build tooling must remain under `Samples~/MobileServicesSamples/Editor/`. Honour the workspace `UnityEditor.Editor` namespace-collision rule for any Unity inspector base classes (qualify as `UnityEditor.Editor`). -- **Native bridges live in `Plugins/iOS/.m`** — one `.m` per subsystem, paired with a backend C# class that owns the `[DllImport("__Internal")]` declarations and routes through it. iOS-side preset/permission/status enums in the `.m` file MUST mirror the C# enum integer values one-to-one; see Phase 5's `GLAppPermission` / `GLPermissionStatus` and Phase 2's `GLHapticPresetId` for the pattern. -- **`UnitySendMessage` GameObject names are contracts** — the iOS `.m` files address `DeviceServicesHost`, `PermissionsCallbackReceiver`, and `AttCallbackReceiver` by string. Renaming the C# `MonoBehaviour` requires updating the matching `.m` file. -- **Tests** live under `Tests/{EditMode,PlayMode}/` with one asmdef each. The UPM sample bundle ships no test assembly; reusable package behavior belongs in package tests, while sample acceptance uses the actual Package Manager import. **Editor tooling is not automated-tested** — types under `Editor/` (`MobileServicesDeviceSimulatorPlugin`, `MobileSimulatorRuntimeOverlay`, `MobileSimulatorState`, `MockBuilders`, `EditorPlatformSimulator`, `MobileServicesConfig` / `MobileServicesConfigEditor` / `MobileServicesConfigMenuItems`, `MobileServicesScanner`, `MobileServicesBuildContext`, `MobileServicesBuildPostprocessor`) are validated by manual editor smoke + on-device builds; see `Tests/AGENTS.md` §3 and §13 for the policy and rationale. Runtime tests do NOT mirror the runtime folder structure — group by feature, not by source path. -- **Sample interaction acceptance**: a green package suite, direct callback invocation, or a Console entry does not prove the imported sample is usable. After a confirmed fresh compilation, use the project `unity-play-verify` skill against the imported bundle and exercise real pointer down/up/cancel, every distinct button wiring, content dragging (not only the scrollbar), fixed bottom navigation, safe areas, and responsive wrapping. Record whether Unity MCP, computer-use, or direct invocation supplied each observation; never report direct invocation as a successful click test. -- Before reading, editing, or creating any file in `Tests/`, you **MUST** read [`Tests/AGENTS.md`](Tests/AGENTS.md) first. - -## 4. Important Behaviors / Gotchas -- **NativeUiService is platform-gated** - - In `UNITY_EDITOR` it logs and does nothing. - - In unsupported platforms it throws `SystemException`. -- **iOS alert callbacks are matched by button text** - - `NativeUiService` stores buttons in a static array and invokes callbacks by matching `AlertButton.Text`. - - Keep button texts unique per alert to avoid ambiguous matches. -- **Notifications host object is created at runtime** - - `MobileNotificationService` creates a `GameObject("NotificationService")` and adds `GameNotificationsMonoBehaviour`. - - This object is marked `DontDestroyOnLoad`, so tests or “reset game” flows may need explicit teardown. - - The service owns that host and implements `IDisposable`. Disposal is idempotent, detaches event forwarding, and destroys only its own host; it does not cancel notifications already handed to the operating system. Public operations and public state access throw `ObjectDisposedException` afterwards. Keep `ThrowIfDisposed()` on that public surface: the guard prevents calls from reaching a destroyed Unity object and is part of the service contract, not defensive decoration. - - Keep editor conditionals at the actual semantic boundaries: editor notification creation/scheduling, simulator delivery, and edit-mode `DestroyImmediate`. Shared argument validation, disposal state, event forwarding, cancellation, and public contract behavior remain outside `UNITY_EDITOR` blocks. -- **Android notification channels** - - If you pass channels, the first one is treated as the default channel id. - - If you schedule without a channel on Android, ensure `DefaultChannelId` is set (via initialization with at least one channel). -- **Queueing vs immediate scheduling** - - In `OperatingMode.Queue*`, notifications may be queued while foregrounded and only scheduled with the OS when the app backgrounds. - - Foreground/background transitions are handled via `OnApplicationFocus`. -- **GestureController threshold interplay** - - If `minSwipeDistance <= maxTapDrift`, a single interaction can qualify as both tap and swipe depending on travel distance and other thresholds. - - `GestureController` requires `EnhancedTouchSupport` to be enabled and manages its own finger-event subscriptions. Unity reference-counts matching `EnhancedTouchSupport.Enable()` / `Disable()` calls, so every controller must balance only its own acquisition: disabling one controller must not break another live owner, and cleanup must tolerate another owner releasing the facility first. - - For mouse input in Editor, add `TouchSimulation` component to convert mouse to touch. -- **Haptics auto-stop coroutine cancellation** - - Each `Play*` call cancels the previous auto-stop coroutine before scheduling its own. Looping calls (`PlayPresetDuration(preset, -1)`) leave NO auto-stop pending; the caller MUST invoke `StopCurrentHaptic()` (or set `Enabled = false`). - - `HapticsHost` is spawned lazily on first play; subsequent calls reuse it. Resetting the game without calling `StopCurrentHaptic()` first leaves the haptic looping until the host is destroyed. -- **Device subsystem GameObjects** - - The umbrella creates up to four `DontDestroyOnLoad` GameObjects on first use: `DeviceServicesHost` (shared poller), `PermissionsCallbackReceiver` (only on iOS, only after the first `RequestAsync`), `AttCallbackReceiver` (only on iOS, only after the first `RequestAuthorizationAsync`), and `HapticsHost` (only after the first haptic with auto-stop). Tests / "reset game" flows that destroy DDOL scenes need to recreate the umbrella afterwards. - - `iOS Battery.m` and `Permissions.m` and `Att.m` all use `UnitySendMessage` against fixed GameObject names — the C# MonoBehaviour names MUST match (`DeviceServicesHost`, `PermissionsCallbackReceiver`, `AttCallbackReceiver`). Renaming requires updating both sides. -- **Permissions: Android API 33+ runtime requirements** - - `READ_MEDIA_IMAGES` and `POST_NOTIFICATIONS` are runtime-required from API 33 (Android 13). Below 33 the OS auto-grants them; the `IPermissionsService` returns `Granted` immediately on those older API levels via the same code path (Unity's `Permission.HasUserAuthorizedPermission` short-circuits). - - Manifest entries for these permissions are added to the generated application manifest by the postprocessor, unless `ManageNativeBuildManually` is enabled. -- **DeepLinkService cold-start link replay** - - The cold-start link (captured from `Application.absoluteURL` at construction) is replayed to the FIRST subscriber only — subsequent subscribers do NOT receive it. This is intentional: the link represents a single user action, not a state. - - Construct the service early in app bootstrap (before scene load) to avoid a race where Unity has already cleared `Application.absoluteURL` by the time the service is instantiated. -- **AttService never throws on Android / Editor** - - Both methods return `AttStatus.Authorized` synchronously on non-iOS platforms. Don't read this as "the user authorized" — read it as "the platform doesn't apply ATT". Conditionalize tracking-init code on `Application.platform == RuntimePlatform.IPhonePlayer` if you care about the distinction. -- **Editor Permission/ATT default depends on whether the simulator is engaged** - - With no override installed (headless tests, no Device Simulator panel) the editor short-circuits to `Granted` / `Authorized` — this is what the EditMode tests assert. When the Device Simulator panel `Engage()`s, `PermissionsService` / `AttService` instead read the `EditorPrefs`-backed simulated store (default `NotDetermined`) and the first `RequestAsync()` / `RequestAuthorizationAsync()` shows the overlay prompt. The override is process-wide static, so the panel `Disengage()`s on close / master-switch-off to avoid leaking the `NotDetermined` default into an unrelated bare-service read. Tests do not open the panel, so they are unaffected. -- **Runtime simulator overlay is edit+play-capable and Editor-asmdef-owned** - - `MobileSimulatorRuntimeOverlay` lives in the Editor asmdef and spawns a `[EditorOnly]` GameObject (UIDocument is `[ExecuteAlways]`, so it paints in the edit-mode Game / Simulator view too). A single idempotent `RefreshLifecycle()` keeps it alive while the Device Simulator plugin panel is open (`NotifyPluginActive`, edit OR play). `DontDestroyOnLoad` is only called in play mode; `DestroyStaleHosts()` de-dups after a domain reload. Interaction inside the mock is unreliable in the edit-mode Game view, so dismissal is driven from the plugin panel's per-section dismiss buttons (the overlay is display-only). The `[EDITOR SIMULATOR]` watermark is shown only while `MobileSimulatorState.Enabled` is on. - - **Overlay PanelSettings scale**: the mock USS (`MobileSimulator.*.uss`) is authored in logical-point units, so the overlay's `PanelSettings` MUST use `scaleMode = ScaleWithScreenSize` with `referenceResolution = (390, 844)` (a logical-phone size). The Device Simulator reports `Screen.width/height` in PHYSICAL pixels, so this yields a scale ≈ the device's native scale (~3x) → 1 USS px ≈ 1 iOS point. Using `ConstantPixelSize` (1 USS px = 1 device px) makes every mock render ~1/3 size on a 3x screen — the symptom is "alerts/toasts are tiny." Do not switch the scale mode back. - - Do NOT subscribe to `MobileSimulatorState` events from a non-editor assembly expecting them to fire in a player build — the broker, the events, and the overlay all live in `GameLovers.MobileServices.Editor`. The runtime `UIDocument` it spawns is a real runtime component but exists in-editor only. - - `PanelSettings.sortingOrder = short.MaxValue` resolves ties via GameObject name lexicographic order; the host GameObject's leading `[` puts it near the top of any sort. If a consumer pins a competing UIDocument to the same sortingOrder *and* names it with a leading character that sorts after `[`, the overlay loses the tie — acceptable, documented. - -## 5. Coding Standards (Unity 6 / C# 9.0) -- **C#**: C# 9.0 syntax; explicit namespaces; no global usings. -- **Assemblies** - - Runtime must not reference `UnityEditor` (guard any editor-only helpers with `#if UNITY_EDITOR`). - - Keep iOS/Android code behind platform defines (`#if UNITY_IOS`, `#if UNITY_ANDROID`). -- **Interop** - - For iOS, keep native symbols in `Plugins/iOS/*` stable when changing `[DllImport("__Internal")]` signatures. - - For Android JNI calls, ensure objects are disposed (`using` blocks are preferred, as in `NativeUiService`). - -## 6. External Package Sources (for API lookups) -When you need third-party source/docs, prefer the locally-cached UPM packages: -- Mobile Notifications: `Library/PackageCache/com.unity.mobile.notifications@*/` -- Input System: `Library/PackageCache/com.unity.inputsystem@*/` - -## 7. Dev Workflows (common changes) -- **Add a new native UI feature** - - Add the C# surface to `Runtime/NativeUi/*` behind platform defines. - - iOS: add/modify Objective-C in `Plugins/iOS/NativeUi.m` and keep signatures in sync with `[DllImport("__Internal")]`. - - Android: implement via `AndroidJavaObject` or provide a Java/Kotlin plugin if it gets too complex. -- **Add a new notification capability** - - Extend `IGameNotification` only if it can be mapped to both platforms (or clearly document platform-only fields). - - Update the relevant platform notification wrappers (`AndroidGameNotification`, `iOSGameNotification`) and platform scheduling behavior. - - If data must persist across background/foreground, update `SerializableNotification` + conversion helpers. -- **Add or adjust Android channels** - - Update construction site(s) where `MobileNotificationService` is initialized. - - Ensure at least one channel is registered; confirm default channel behavior matches expectations. -- **Change gesture detection** - - Adjust thresholds on `GestureController` and document intended UX. - -## 8. Update Policy -Update this file when: -- Public API changes (`NativeUiService`, `INativeUiService`, `INotificationService` + `NotificationBuilder`, `IGameNotification`, `GestureController` events, `IHapticsService`, `IDeviceService` and any of its child interfaces, `IDeepLinkRouter`, `IMobileService`) -- Platform integration changes (JNI calls, iOS native symbols in any `Plugins/iOS/*.m` file, notification platform wrappers, `UnitySendMessage` GameObject names) -- Notification queueing/persistence behavior changes (`OperatingMode`, PlayerPrefs payload shape) -- Gesture detection logic or input source integration changes -- Haptic preset envelopes (`HapticPreset` enum + per-preset time/amplitude tables in `HapticEnvelopes` and per-preset routing in `Plugins/iOS/Haptics.m`) -- Permissions catalogue changes (`AppPermission` enum + `AndroidManifestPermission` mapping + iOS `_GameLoversPermissionsRequest` switch) -- Editor surface changes (`MobileServicesDeviceSimulatorPlugin` panel layout / foldouts / diagnostics, `EditorPlatformSimulator` API, internal introspection accessors, `MobileSimulatorState` broker shape, simulator USS / overlay payloads, `MobileSimulatorRuntimeOverlay` lifecycle / auto-platform-sync behaviour) -- Editor notification simulation changes (`MobileNotificationSimulation` target registration, Notifications Scheduler adapter, explicit delivery and due-time behavior, or the generic-preview fallback) -- `MobileServicesConfig` schema (`[SerializeField]` rows on the SO asset — current set: per-locale usage descriptions, per-locale ATT usage, capability toggles, Android manifest toggles, native deep-link schemes/intent filters, `IncludePlayReviewDependency` + `PlayReviewDependencyCoordinate`, `ManageNativeBuildManually`), the `MobileServicesConfigEditor` Inspector layout, the `Select Mobile Services Config` menu item, the `MobileServicesConfig.Instance` / `TryGetPersistedConfig` / `GetOrCreateAsset` locators, project scanner warning rules, build postprocessor mutation logic (Info.plist keys, localizations, URL schemes, localized strings, entitlements capabilities, Android manifest entries/filters, queries block, gradle dependency injection) -- `docs/` structure changes (new file added, file deleted, file renamed) → update `docs/README.md` index AND the matching link table row in the main `README.md` "Related docs" section -- Sample folder structure or sample-only types change → update the single `Samples~/MobileServicesSamples/README.md`, `docs/samples.md`, `package.json` `samples[]` block, AND the AGENTS.md Samples row, in lockstep; do not add controller/view-level READMEs -- A documented public symbol, serialized field, sample path, dependency, or setup mechanism is removed or renamed → search every package Markdown file for the old identifier/path/phrase and either remove it or keep it only in an explicitly historical migration note +# GameLovers MobileServices — Agent Guide + +This guide adds package-specific rules to the host repository guide. Consumer usage belongs in `README.md` and `docs/`. + +## Scope + +- Package: `com.gamelovers.mobileservices`; minimum Unity version and dependencies are authoritative in `package.json`. +- Runtime subsystems: Native UI, notifications, gestures, haptics, and device services (safe area, battery, audio session, permissions, ATT, and deep links). +- Consumers must enable the Input System or Both. The gesture contract uses EnhancedTouch. +- This package is render-pipeline-neutral and has no dependency on GameLovers Services. + +## Layout and native boundaries + +- Each `Runtime//` owns namespace `GameLovers.MobileServices.`. Its deeper folders are organizational and do not add namespace segments. +- Non-public platform backends and hosts belong under `Internal/` and remain `internal`; use existing `InternalsVisibleTo` grants for Editor/test access. +- Editor simulator, settings, and native build code stay under `Editor/`. Sample build tooling stays under `Samples~/MobileServicesSamples/Editor/`. +- iOS bridges live in `Plugins/iOS/`. `_GameLovers*` exports, C# `DllImport` signatures, enum integer values, and native implementations must change together. +- `UnitySendMessage` names are native contracts: `DeviceServicesHost`, `PermissionsCallbackReceiver`, and `AttCallbackReceiver`. Update C# and Objective-C together if one changes. + +## 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. +- `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. +- Every `GestureController` balances only its own `EnhancedTouchSupport.Enable()` acquisition. Multiple live controllers and external release must remain safe. +- One haptic play replaces the previous play and cancels its pending auto-stop. Negative preset duration loops until explicit stop; `StopCurrentHaptic()` is idempotent. +- `DeviceServicesHost` is the shared polling host. Do not create per-service update GameObjects when the host can own the callback. +- The first `DeepLinkService` subscriber receives a pending cold-start link once. Construct the service early; event names are not persistent state. +- ATT returning `Authorized` outside iOS means “not applicable,” not an observed user decision. +- Permission, ATT, battery, safe-area, native-alert, and review Editor overrides are process-wide statics. Simulator code must install and remove them symmetrically; tests must reset overrides they touch. +- The iOS location permission bridge retains each `CLLocationManager` delegate until authorization changes and removes it only after callback dispatch. Do not simplify the static delegate-retention collection into a local lifetime. +- Android Photos and Notifications permissions use API-33 runtime permissions (`READ_MEDIA_IMAGES` and `POST_NOTIFICATIONS`) and short-circuit as granted on older APIs. Keep runtime checks and generated manifest entries aligned. + +## Editor and native-build invariants + +- The Device Simulator plugin is the single simulator control surface; the runtime overlay is Editor-assembly-owned. UI that claims to affect the game must target the consumer's actual service instance, not a parallel service created by the panel. +- `MobileServicesConfig` is an Editor-only asset. Native build callbacks use the persisted config boundary, never an implicit transient default. +- The package build postprocessor is the sole owner of generated iOS/Android mutation. Sample tooling contributes temporary declarative requirements and a later cleanup callback; it must not implement a competing native mutator. +- Native-build configuration resolves the persisted asset before scanning or touching generated files. Missing config and manual-management mode are no-ops; malformed enabled settings fail before mutation. Package mutation runs at callback order 1000 and sample cleanup at 2000. +- Validate all persisted config and sample build preconditions before changing build scenes, profiles, manifests, plists, entitlements, or generated Gradle files. Repeated mutation must be idempotent and preserve non-empty consumer values. +- New inspectors use UI Toolkit and qualify `UnityEditor.Editor` inside namespaces containing `.Editor`. + +## Samples and tests + +- `package.json` exposes one four-scene sample bundle under `Samples~/MobileServicesSamples/` with one runtime asmdef and one editor asmdef. +- Sample scenes remain independently playable, require no hand-wired scene bootstrap, and share the sample-owned navigation/session. Do not add a uGUI `EventSystem`; Unity InputForUI owns UI Toolkit input. +- Sample build tools derive scene identity from serialized `SceneAsset` references, never hand-authored paths or GUID lookup code. +- Imported sample acceptance requires actual pointer/click/drag/navigation verification after a fresh compile; direct callback invocation is not click evidence. +- The Editor overlay's USS uses logical phone points. Its `PanelSettings` stays `ScaleWithScreenSize` with reference resolution 390×844; `ConstantPixelSize` renders mocks roughly one-third size on high-density simulated devices. +- Before changing anything under `Tests/`, read `Tests/AGENTS.md`. + +## Verification and documentation + +- Platform bridge changes require relevant Editor simulation plus real iOS/Android build or device evidence; unavailable platform evidence is `NOT VALIDATED`. +- Update `README.md`, the relevant `docs/` page, and the canonical sample README when public behavior or sample setup changes. +- Update this guide only for durable subsystem, native-boundary, build-ownership, or test conventions. diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4f8e3..abe4457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.1.0] - 2026-08-13 + +**New**: +- Added non-dismissible alerts for blocking application flows and programmatic alert dismissal without invoking a button callback. +- Runtime alert calls now render an interactive platform-shaped mock in the Editor Game view without requiring the Device Simulator window. + +**Changed**: +- Declared Unity 6000.0 as the package minimum and documented 6000.0.x, 6000.3.x, and 6000.5.x as compatibility reference streams. +- Moved the keep-awake convenience API to the static `DeviceService.KeepAwake` property and removed the redundant `IScreenWakeService` / `ScreenWakeService` child service. +- Alerts now require one to three buttons with unique labels and styles so iOS and Android resolve the same action. + +**Fixed**: +- Android alerts are created and shown on the Android UI thread, retain callback proxies until dismissal, and tolerate buttons with no callback. + ## [1.0.1] - 2026-08-12 **Fixed**: @@ -29,17 +43,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Device Simulator Plugin**: An embedded Unity Device Simulator panel provides platform-shaped native UI mocks, live diagnostics, and a per-preset haptic envelope graph. - **Mobile Services Config asset**: Configure localized permission descriptions, capability toggles, Android manifest opt-ins, and Play In-App Review Gradle setup from `Tools > GameLovers > Mobile Services > Select Mobile Services Config`. - **Build Postprocessor**: Automatically inject iOS usage descriptions and entitlements, Android manifest entries, and the Play In-App Review Gradle dependency, with validation for missing configuration. -- **Samples**: Added one importable **Mobile Services Samples** bundle containing independently playable Playground, Haptics Palette, Notifications Scheduler, and Deep Link Router scenes. +- **Samples**: Added one importable **Mobile Services Samples** bundle containing independently playable Playground, Haptics Palette, Notifications Scheduler, and Deep Link Router scenes. - **Documentation**: Added subsystem references and editor-tooling guides for the Device Simulator and build pipeline. -**Changed**: -- Consolidated the package under the `com.gamelovers.mobileservices` package name, `GameLovers.MobileServices.*` namespaces, and `GameLovers.MobileServices` assembly. -- Updated the package baseline to Unity 6 and documented the supported 6000.5.7f1, 6000.3.21f1, and 6000.0.81f1 validation editors. - -**Fixed**: -- Fixed persisted notifications so nullable IDs, badge numbers, and delivery times survive background/foreground rescheduling. -- Fixed local notification delivered and expired events so subscribers added after service construction receive callbacks. -- Fixed editor notification scheduling so generated notifications appear in the pending collection. +**Changed**: +- Consolidated the package under the `com.gamelovers.mobileservices` package name, `GameLovers.MobileServices.*` namespaces, and `GameLovers.MobileServices` assembly. +- Updated the package baseline to Unity 6 and documented the supported 6000.5.7f1, 6000.3.21f1, and 6000.0.81f1 validation editors. + +**Fixed**: +- Fixed persisted notifications so nullable IDs, badge numbers, and delivery times survive background/foreground rescheduling. +- Fixed local notification delivered and expired events so subscribers added after service construction receive callbacks. +- Fixed editor notification scheduling so generated notifications appear in the pending collection. **Removed**: - Removed legacy tap detection; use the Unity Input System's `TapInteraction` instead. diff --git a/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs index ca3bd60..942b4df 100644 --- a/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs +++ b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using GameLovers.MobileServices.NativeUi; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; @@ -11,14 +13,13 @@ namespace GameLovers.MobileServices.Editor.Explorer.Overlays /// device's Screen.* values. /// /// - /// The overlay is alive whenever the Device Simulator plugin panel is open (edit OR play - /// mode) so a designer can fire a mock from the panel and see it inside the simulated phone - /// without entering play mode. A single idempotent drives spawn / - /// teardown. + /// The overlay is alive whenever the Device Simulator plugin panel is open or a runtime + /// alert is visible, including a plain Game view with no simulator window. A single idempotent + /// drives spawn / teardown. /// The overlay renders inside Unity's runtime UIToolkit panel - /// is [ExecuteAlways], so its panel paints into the Game / Device Simulator view in edit - /// mode too. Interaction inside the mock is unreliable in the edit-mode Game view, so dismissal - /// is driven from the plugin panel; the overlay is treated as display-only. + /// mode too. Runtime play-mode alerts are interactive; edit-mode panel previews retain their + /// panel-owned dismissal controls. /// The instance is constructed programmatically (rather than /// shipped as a .asset) to keep the setup editor-only by construction. /// @@ -34,14 +35,17 @@ internal static class MobileSimulatorRuntimeOverlay private static OverlayController _controller; private static PanelSettings _panelSettings; private static bool _pluginActive; + private static bool _standaloneAlertActive; - private static bool ShouldBeAlive => _pluginActive; + private static bool ShouldBeAlive => _pluginActive || _standaloneAlertActive; static MobileSimulatorRuntimeOverlay() { // Re-evaluate across play-mode transitions so the host's DontDestroyOnLoad / teardown is // applied correctly when the panel is open while entering or exiting play mode. EditorApplication.playModeStateChanged += _ => RefreshLifecycle(); + NativeUiService.EditorShowAlertOverride = ShowAlert; + NativeUiService.EditorDismissAlertOverride = DismissAlert; } /// @@ -55,6 +59,51 @@ internal static void NotifyPluginActive(bool active) RefreshLifecycle(); } + /// Paints one runtime-requested alert through the Editor simulator overlay. + internal static void ShowAlert( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + AlertButton[] buttons) + { + _standaloneAlertActive = true; + EnsureSpawned(); + + var simulatedButtons = new List(buttons.Length); + foreach (var button in buttons) + { + simulatedButtons.Add(new SimulatedAlertButton + { + Text = button.Text, + Style = (SimulatedAlertButtonStyle)button.Style, + OnClicked = button.Callback, + }); + } + + MobileSimulatorState.PushAlert(new SimulatedAlertSpec + { + Title = title, + Message = message, + IsActionSheet = isAlertSheet, + IsDismissible = isDismissible, + Buttons = simulatedButtons, + }); + } + + /// Dismisses the active Editor alert without invoking an action. + internal static void DismissAlert() + { + if (_controller != null) + { + MobileSimulatorState.PushDismissAll(); + return; + } + + _standaloneAlertActive = false; + RefreshLifecycle(); + } + private static void RefreshLifecycle() { if (ShouldBeAlive) @@ -323,7 +372,7 @@ private void OnAlert(SimulatedAlertSpec spec) { ClearStage(); ShowStage(); - _stage.Add(MockBuilders.BuildAlert(MobileSimulatorState.Platform, spec, ClearStage)); + _stage.Add(MockBuilders.BuildAlert(MobileSimulatorState.Platform, spec, DismissAlert)); } private void OnToast(SimulatedToastSpec spec) @@ -384,8 +433,15 @@ private void OnPermissionDialog(SimulatedPermissionDialogSpec spec) } private void OnDismissAll() + { + DismissAlert(); + } + + private void DismissAlert() { ClearStage(); + _standaloneAlertActive = false; + EditorApplication.delayCall += RefreshLifecycle; } private void ShowStage() diff --git a/Editor/Explorer/Overlays/MobileSimulatorState.cs b/Editor/Explorer/Overlays/MobileSimulatorState.cs index 375234f..45648ff 100644 --- a/Editor/Explorer/Overlays/MobileSimulatorState.cs +++ b/Editor/Explorer/Overlays/MobileSimulatorState.cs @@ -48,6 +48,7 @@ public sealed class SimulatedAlertSpec public string Title; public string Message; public bool IsActionSheet; + public bool IsDismissible = true; public List Buttons = new List(); } diff --git a/Editor/Explorer/Overlays/MockBuilders.cs b/Editor/Explorer/Overlays/MockBuilders.cs index af4c10a..89d7c94 100644 --- a/Editor/Explorer/Overlays/MockBuilders.cs +++ b/Editor/Explorer/Overlays/MockBuilders.cs @@ -60,8 +60,8 @@ internal static VisualElement BuildAlert(SimulatedPlatform platform, SimulatedAl { var btn = new Button(() => { - btnSpec.OnClicked?.Invoke(); dismissCallback?.Invoke(); + btnSpec.OnClicked?.Invoke(); }) { text = btnSpec.Text }; btn.AddToClassList("mock-card-button"); switch (btnSpec.Style) diff --git a/Editor/Simulation/EditorPlatformSimulator.cs b/Editor/Simulation/EditorPlatformSimulator.cs index 202a1d1..59b1871 100644 --- a/Editor/Simulation/EditorPlatformSimulator.cs +++ b/Editor/Simulation/EditorPlatformSimulator.cs @@ -136,6 +136,8 @@ public static void Engage() PermissionsService.EditorRequestAsyncOverride = RequestPermissionAsync; AttService.EditorCurrentStatusOverride = ReadAttStore(); AttService.EditorRequestAsyncOverride = RequestAttAsync; + NativeUiService.EditorShowAlertOverride = MobileSimulatorRuntimeOverlay.ShowAlert; + NativeUiService.EditorDismissAlertOverride = MobileSimulatorRuntimeOverlay.DismissAlert; // Review is fire-and-forget (no OS success callback) — the same RequestReview() the game // calls drives the overlay mock in edit + play mode, mirroring the Permissions / ATT hooks. NativeUiService.EditorRequestReviewOverride = () => MobileSimulatorState.PushReview(); @@ -155,6 +157,8 @@ public static void Disengage() AttService.EditorCurrentStatusOverride = null; AttService.EditorRequestResultOverride = null; AttService.EditorRequestAsyncOverride = null; + NativeUiService.EditorShowAlertOverride = MobileSimulatorRuntimeOverlay.ShowAlert; + NativeUiService.EditorDismissAlertOverride = MobileSimulatorRuntimeOverlay.DismissAlert; NativeUiService.EditorRequestReviewOverride = null; _pendingPermissionResolvers.Clear(); _pendingAttResolver = null; diff --git a/Plugins/iOS/NativeUi.m b/Plugins/iOS/NativeUi.m index 9d93721..cbaedd4 100644 --- a/Plugins/iOS/NativeUi.m +++ b/Plugins/iOS/NativeUi.m @@ -9,6 +9,8 @@ typedef void (*AlertButtonCallback)(const char * str); +static UIAlertController *GameLoversCurrentAlert; + void _GameLoversAlertMessage (bool isSheet, char* title, char* message, char* buttonsText[], int buttonsStyle[], int buttonsLength, AlertButtonCallback buttonCallback) { UIAlertControllerStyle style = isSheet ? UIAlertControllerStyleActionSheet : UIAlertControllerStyleAlert; @@ -17,16 +19,35 @@ void _GameLoversAlertMessage (bool isSheet, char* title, char* message, char* bu for (int i = 0; i < buttonsLength; i++) { NSString *buttonText = ToNSString(buttonsText[i]); - int index = i; UIAlertAction * button = [UIAlertAction actionWithTitle:buttonText style:(UIAlertActionStyle)buttonsStyle[i] handler:^(UIAlertAction * action) { + GameLoversCurrentAlert = nil; buttonCallback((char*)[buttonText UTF8String]); }]; [alert addAction:button]; } dispatch_async(dispatch_get_main_queue(), ^{ - [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil]; + void (^presentAlert)(void) = ^{ + GameLoversCurrentAlert = alert; + [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil]; + }; + if (GameLoversCurrentAlert != nil) + { + [GameLoversCurrentAlert dismissViewControllerAnimated:NO completion:presentAlert]; + } + else + { + presentAlert(); + } + }); +} + +void _GameLoversDismissAlert(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [GameLoversCurrentAlert dismissViewControllerAnimated:YES completion:nil]; + GameLoversCurrentAlert = nil; }); } diff --git a/README.md b/README.md index f786b40..4a57c01 100644 --- a/README.md +++ b/README.md @@ -1,238 +1,88 @@ # GameLovers Mobile Services -[![Unity Version](https://img.shields.io/badge/Unity-6000.0%20%7C%206000.3%20%7C%206000.5-blue.svg)](https://unity3d.com/get-unity/download) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/github/v/tag/CoderGamester/com.gamelovers.mobileservices?label=version)](CHANGELOG.md) +Unity 6 services for local notifications, native UI, haptics, permissions, App Tracking Transparency, deep links, gestures, and mobile build tooling. -> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Samples](#samples) | [Related docs](#related-docs) | [Contributing](#contributing) +[![Unity](https://img.shields.io/badge/Unity-6000.0%20%7C%206000.3%20%7C%206000.5-blue.svg)](https://unity.com/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) +[![Version](https://img.shields.io/github/v/tag/CoderGamester/Unity-MobileServices?label=version)](CHANGELOG.md) -## Why Use This Package? +## Scope -Building mobile-specific features in Unity often requires dealing with platform-specific code, native bridges, and fragmented APIs. This **Mobile Services** package consolidates essential mobile functionality into a unified, easy-to-use API: +Use Mobile Services to isolate platform-specific behavior behind Unity-friendly APIs. It provides **local** notifications, not remote push delivery; it also does not provide connectivity or store fallback services. The package is pipeline-neutral. -| Problem | Solution | -|---------|----------| -| **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, review prompts, and share sheets with one API | -| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management + a fluent `service.Schedule().In(...).Title(...).Send()` builder | -| **Custom gesture detection** | Gesture controller provides swipe and tap detection via Unity's EnhancedTouch | -| **Haptic plugin sprawl** | Zero-dependency `IHapticsService` with 9 presets, custom intensity, and time-bounded looping — built directly on iOS/Android primitives | -| **Scattered device APIs** | One `IDeviceService` umbrella over `SafeArea`, `ScreenWake`, `Battery`, `AudioSession`, `Permissions`, `Att`, `DeepLink` — each child also independently mockable | -| **Deep-link routing boilerplate** | `IDeepLinkRouter.MapRoute("/promo/:id", handler)` over `IDeepLinkService` | -| **iOS silent switch muting audio** | `device.AudioSession.ConfigureForPlayback()` overrides `AVAudioSession` category in one line | -| **iOS App Tracking Transparency** | `device.Att.RequestAuthorizationAsync()` — direct `ATTrackingManager` bridge, no `com.unity.ads.ios-support` dependency | -| **Cold-start deep link loss** | `device.DeepLink` queues the launch link for the first subscriber so you never miss it | -| **Forgotten `Info.plist` keys → App Store rejection** | Mobile Services Config asset + build postprocessor auto-inject `NS*UsageDescription` keys (localized per device language), entitlements, and Android manifest entries; fail-fast validation lists every missing key | -| **Editor testing challenges** | A Device Simulator plugin panel paints platform-shaped mocks inside the simulated phone (edit + play) with live diagnostics; `EditorPlatformSimulator` drives state for unit tests | +## Unity compatibility -**Built for production:** Uses Unity's official Mobile Notifications and Input System packages. Tested in real mobile games. +| Item | Current policy | +| --- | --- | +| Minimum Unity version | `6000.0` | +| Reference streams | `6000.0.x`, `6000.3.x`, `6000.5.x` | +| Reference editors | `6000.0.81f1`, `6000.3.21f1`, `6000.5.7f1` (primary) | +| Render pipeline | Pipeline-neutral | +| Validation status | Compatibility target; do not treat a stream as validated until the repository matrix records it. | ---- +| Platform | Intended behavior | +| --- | --- | +| iOS / Android | Native services and build-time configuration | +| Editor | Platform simulator and no-op/mock backends where applicable | +| Standalone | Limited fallback behavior; haptics reports unsupported | +| WebGL | Not supported | -## System Requirements - -- **[Unity](https://unity.com/download)** Unity 6 only. The supported validation matrix is: - - | Stream | Exact validation editor | - |---|---| - | Primary 6000.5.x | 6000.5.7f1 | - | Supported 6000.3.x | 6000.3.21f1 | - | Supported 6000.0.x | 6000.0.81f1 | - - Other Unity versions are unsupported/untested. -- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) — automatically resolved -- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) — automatically resolved; set **Active Input Handling** to **Input System Package (New)** or **Both** so Unity 6 routes the sample's UI Toolkit input through InputForUI, without a uGUI dependency - -| Platform | Status | -|---|---| -| iOS | ✅ Fully supported | -| Android | ✅ Fully supported | -| Editor | ✅ Supported (no-op fallbacks + truth-mirror simulator) | -| Standalone | ⚠️ Gestures + SafeArea + Battery (level/status); Haptics returns `IsSupported = false`; iOS audio session / ATT are no-ops | -| WebGL | ❌ Not supported | - -## Installation - -### Via Unity Package Manager (Recommended) - -1. Open Unity Package Manager (`Window` → `Package Manager`) -2. Click `+` → `Add package from git URL` -3. Enter: `https://github.com/CoderGamester/com.gamelovers.mobileservices.git` - -### Via manifest.json +## Install and configure native projects ```json { "dependencies": { - "com.gamelovers.mobileservices": "https://github.com/CoderGamester/com.gamelovers.mobileservices.git" + "com.gamelovers.mobileservices": "https://github.com/CoderGamester/Unity-MobileServices.git#1.0.1" } } ``` ---- +Before using permissions, notifications, ATT, or native UI: -## Quick Start +1. Create and commit the Mobile Services settings/config asset. +2. Fill in every required usage description and capability for the platforms you ship. +3. Decide whether the package or your project owns generated native files. +4. Validate an iOS and Android build on physical devices. -### Native UI +Without persisted configuration, the build postprocessor has no configuration to apply; it cannot infer missing privacy keys or capabilities. -```csharp -using GameLovers.MobileServices.NativeUi; - -NativeUiService.ShowAlertPopUp( - isAlertSheet: false, - title: "Delete Save?", - message: "This action cannot be undone.", - new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, - new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, Callback = OnDeleteConfirmed }); - -NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); -NativeUiService.RequestReview(); // Android Play Review dependency is auto-injected at build time -NativeUiService.Share(text: "Check out my high score!", url: "https://example.com/game"); -``` +## First success -### Notifications - -```csharp -using GameLovers.MobileServices.Notifications; - -var service = new MobileNotificationService( - new GameNotificationChannel("default", "Default", "Default notifications"), - new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders")); - -service.Schedule() - .In(TimeSpan.FromHours(24)) - .Title("Daily Reward Ready!") - .Body("Your daily reward is waiting for you!") - .Channel("rewards") - .BadgeIncrement() - .Send(); -``` - -### Device - -```csharp -using GameLovers.MobileServices.Device; - -IDeviceService device = new DeviceService(); - -device.Battery.OnLowPowerModeChanged += () => Debug.Log($"LPM -> {device.Battery.IsLowPowerMode}"); -device.ScreenWake.KeepAwake = true; -device.AudioSession.ConfigureForPlayback(); - -var perms = await device.Permissions.RequestAsync(AppPermission.Camera, AppPermission.Microphone); -if (perms[AppPermission.Camera] == PermissionStatus.Granted) { /* … */ } - -var att = await device.Att.RequestAuthorizationAsync(); - -device.DeepLink.OnLinkActivated += uri => Debug.Log($"Deep link: {uri}"); - -// Or with the router: -var router = new DeepLinkRouter(device.DeepLink, routes => -{ - routes.MapRoute("/promo/:id", (uri, p) => OpenPromo(p["id"])); -}); -``` - -### Haptics +Create owners during application startup and dispose them during teardown. Scheduling a notification transfers it to the operating system; disposing its service does not cancel already-scheduled OS notifications. ```csharp +using System; using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Notifications; +using Unity.Notifications.Android; -IHapticsService haptics = new HapticsService(); -haptics.PlayPreset(HapticPreset.Success); -haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, duration: 0.5f); // auto-stop after 0.5s -haptics.PlayCustom(intensity01: 0.7f, durationMs: 250f); -haptics.StopCurrentHaptic(); -``` - -### Umbrella facade +var haptics = new HapticsService(); +haptics.Play(HapticPreset.Selection); -```csharp -using GameLovers.MobileServices; +var notifications = new MobileNotificationService( + new GameNotificationChannel("default", "Default", "General notifications")); -IMobileService mobile = new MobileService(); // bind once -mobile.NativeUi.ShowToastMessage("hi", false); -mobile.Notifications.Schedule().In(TimeSpan.FromHours(1)).Title("x").Send(); -mobile.Haptics.PlayPreset(HapticPreset.Selection); -var camera = await mobile.Device.Permissions.RequestAsync(AppPermission.Camera); +// Keep this owner and call Dispose when your application service is torn down. +IDisposable ownedNotifications = notifications; ``` ---- - -## Services at a Glance - -| Service | Purpose | -|---------|---------| -| `NativeUiService` (static) + `INativeUiService` (instance) | Alerts, sheets, toasts, review, share | -| `INotificationService` / `MobileNotificationService` | Local + remote notifications with channel registration, fluent `Schedule()` builder, and 4 `OperatingMode`s | -| `GestureController` | EnhancedTouch swipe + tap detection | -| `IHapticsService` / `HapticsService` | 9 cross-platform presets + custom intensity + time-bounded looping | -| `IDeviceService` / `DeviceService` | Umbrella over `SafeArea`, `ScreenWake`, `Battery`, `AudioSession`, `Permissions`, `Att`, `DeepLink` | -| `IDeepLinkRouter` / `DeepLinkRouter` | Path-pattern routing over `IDeepLinkService` | -| `IMobileService` / `MobileService` | Package-wide umbrella facade (NativeUi / Notifications / Haptics / Device) | -| `SafeAreaContainer` | UI Toolkit `VisualElement` that pads itself to the safe area | - -For full per-subsystem API reference, see [`docs/`](docs/README.md). - ---- - -## Editor tooling - -Runtime simulation and diagnostics live inside Unity's Device Simulator: - -- **`Window > General > Device Simulator`** — a **Mobile Services** panel appears automatically in the Control Panel. It bundles the controls (alerts / toasts / share / haptics / notifications / gestures / permissions / ATT / app review), live-state diagnostics, and a per-preset haptic envelope graph. Firing a mock paints it **inside the simulated phone screen** at the right scale and safe area, in **edit and play mode** — no second window, no platform toggle to keep in sync (the skin auto-syncs from the selected device profile). -- **Notification scheduler connection** — when the `NotificationsScheduler` sample is active in Play Mode, the panel's **Deliver next pending** action and due-time poll drive that sample's own service and paint its exact notification payload; without an active sample, **Show heads-up banner** remains a generic editor preview. -- **`EditorPlatformSimulator`** — static API for driving device / permission / ATT / deep-link state from edit-mode tests and scripted automation. - -Plus a **Mobile Services Config** asset (open via **`Tools > GameLovers > Mobile Services > Select Mobile Services Config`**) for per-permission localized usage descriptions, capability toggles, semantic iOS/Android deep-link registrations, and the auto-injection build postprocessor. The build callback resolves this persisted asset explicitly; with no persisted asset (and no temporary sample context) it performs no native mutation. - -See [`docs/explorer.md`](docs/explorer.md) and [`docs/build-pipeline.md`](docs/build-pipeline.md) for the full guide. - ---- - -## Samples - -Import the single **Mobile Services Samples** bundle from `Window > Package Manager > Mobile Services > Samples`. It is one sample with four ready-to-open UI Toolkit views. The imported bundle's single [sample README](Samples~/MobileServicesSamples/README.md) documents their shared setup and view-specific workflows. - -| Sample | Purpose | -|--------|---------| -| [Overview](Samples~/MobileServicesSamples/README.md#overview) | Native UI, permissions, device state, ATT, gestures, and safe-area wiring. | -| [Haptics](Samples~/MobileServicesSamples/README.md#haptics) | Designer iteration tool with sequence recorder + replay. | -| [Notifications](Samples~/MobileServicesSamples/README.md#notifications) | Fixed-channel scheduling/cancellation and `OperatingMode` lifecycle demo. | -| [Links](Samples~/MobileServicesSamples/README.md#links) | Route-pattern, raw-link, and cold-start replay demo. | - -Each scene opens directly from the Project window and remains independently playable. In the combined player, persistent bottom tabs navigate between **Overview**, **Haptics**, **Notifications**, and **Links**; a received OS deep link opens the Links tab automatically. Enter Play Mode before using buttons, fields, scrolling, or navigation in the Game/Simulator view; Unity 6 InputForUI routes input to UI Toolkit while the shared navigation supplies the gesture bridge, so no GameObject wiring is required. Every enabled button has visual hover/press/focus feedback and emits one `Selection` haptic when its click commits; a drag that crosses the scroll threshold cancels the button and scrolls instead. Sample ScrollViews are clamped, drag smoothly from content or controls (with a gesture fallback for simulator touch streams), and keep the bottom navigation pinned. Status cards use one `Field: Value` per line. - -The only supported player output contains all four scenes, starting on Overview. Choose **Tools > Mobile Samples Examples > Build All** to validate the imported serialized `SceneAsset` catalog, install that exact ordered scene list, and open Unity's native Build Profiles window. Choose **Restore All** in the same menu to restore the prior scene configuration during the current Unity session. The sample contributes native requirements through the shared `MobileServicesConfig` pipeline only for that exact four-scene build; the package postprocessor remains the sole native mutator. Use **Tools > Mobile Samples Examples > Verify Scene Catalog** to emit current page/path/derived-GUID identities. These sample-owned menu items and their editor bridge are never included in player builds. See [`docs/samples.md`](docs/samples.md) for setup details. - ---- - -## Related docs - -| Document | Purpose | -|---|---| -| [docs/README.md](docs/README.md) | Full API reference index | -| [docs/native-ui.md](docs/native-ui.md) | Native UI deep dive | -| [docs/notifications.md](docs/notifications.md) | Notifications deep dive (channels, modes, builder, persistence) | -| [docs/haptics.md](docs/haptics.md) | Haptics deep dive (presets, envelope, looping, backends) | -| [docs/gestures.md](docs/gestures.md) | Gesture detection deep dive | -| [docs/device.md](docs/device.md) | Device umbrella + 8 children + DeepLinkRouter | -| [docs/explorer.md](docs/explorer.md) | Device Simulator panel & in-Game-view simulator overlay | -| [docs/build-pipeline.md](docs/build-pipeline.md) | Project Settings + build postprocessor (and manual fallback) | -| [docs/samples.md](docs/samples.md) | Samples index | -| [docs/troubleshooting.md](docs/troubleshooting.md) | Symptom-to-fix table | -| [docs/superpowers/README.md](docs/superpowers/README.md) | Approved design records and implementation plans | -| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | -| [CHANGELOG.md](CHANGELOG.md) | Version history | +Use the specific subsystem namespaces—`Notifications`, `Haptics`, `NativeUi`, and `Device`—rather than assuming one umbrella import exposes every type. -## Contributing +## Services -Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). +| Area | Provides | +| --- | --- | +| Native UI | Dismissible or blocking alerts, action sheets, toasts, review requests, and sharing | +| Notifications | Local notification channels, scheduling, and management | +| Haptics | Presets, custom output, and bounded loops | +| Device | Permissions, ATT, deep links, safe-area and device helpers | +| Gestures | Gesture controller for explicit gesture input ownership | +| Editor tooling | Device Simulator integration and build helpers | -## Support +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. -- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) -- **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/com.gamelovers.mobileservices/discussions) +## Sample and support -## License +Import **Mobile Services Samples** from Package Manager. Its four scenes—Overview, Haptics, Notifications, and Links—share one sample player; use its [README](Samples~/MobileServicesSamples/README.md) for scene prerequisites and build tooling. -MIT — see [LICENSE.md](LICENSE.md). +See [docs](docs/README.md), [CHANGELOG.md](CHANGELOG.md), and [issues](https://github.com/CoderGamester/Unity-MobileServices/issues). diff --git a/Runtime/Device/DeviceService.cs b/Runtime/Device/DeviceService.cs index e04ae7e..3010966 100644 --- a/Runtime/Device/DeviceService.cs +++ b/Runtime/Device/DeviceService.cs @@ -1,5 +1,6 @@ using System; using GameLovers.MobileServices.Device.Internal; +using UnityEngine; // ReSharper disable once CheckNamespace namespace GameLovers.MobileServices.Device @@ -7,11 +8,20 @@ namespace GameLovers.MobileServices.Device /// public sealed class DeviceService : IDeviceService, IDisposable { + /// + /// Controls whether the device screen stays awake. When true, sets + /// Screen.sleepTimeout to SleepTimeout.NeverSleep; when false, + /// restores SleepTimeout.SystemSetting. + /// + public static bool KeepAwake + { + get => Screen.sleepTimeout == SleepTimeout.NeverSleep; + set => Screen.sleepTimeout = value ? SleepTimeout.NeverSleep : SleepTimeout.SystemSetting; + } + /// public ISafeAreaService SafeArea { get; } /// - public IScreenWakeService ScreenWake { get; } - /// public IBatteryService Battery { get; } /// public IIosAudioSessionService AudioSession { get; } @@ -26,7 +36,6 @@ public DeviceService() : this(BuildDefaults()) { } public DeviceService( ISafeAreaService safeArea, - IScreenWakeService screenWake, IBatteryService battery, IIosAudioSessionService audioSession, IPermissionsService permissions, @@ -34,7 +43,6 @@ public DeviceService( IDeepLinkService deepLink) { SafeArea = safeArea; - ScreenWake = screenWake; Battery = battery; AudioSession = audioSession; Permissions = permissions; @@ -45,20 +53,19 @@ public DeviceService( // Tuple-routed delegating ctor so the host-dependent children share one explicit host // instance constructed up-front, not separate accesses to the singleton during a // constructor chain (cleaner ownership signal in the umbrella's call stack). - private DeviceService((ISafeAreaService, IScreenWakeService, IBatteryService, + private DeviceService((ISafeAreaService, IBatteryService, IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) defaults) : this(defaults.Item1, defaults.Item2, defaults.Item3, - defaults.Item4, defaults.Item5, defaults.Item6, defaults.Item7) + defaults.Item4, defaults.Item5, defaults.Item6) { } - private static (ISafeAreaService, IScreenWakeService, IBatteryService, + private static (ISafeAreaService, IBatteryService, IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) BuildDefaults() { var host = DeviceServicesHost.Instance; return ( new SafeAreaService(host), - new ScreenWakeService(), new BatteryService(host), new IosAudioSessionService(), new PermissionsService(), diff --git a/Runtime/Device/IDeviceService.cs b/Runtime/Device/IDeviceService.cs index aeb05b9..436a841 100644 --- a/Runtime/Device/IDeviceService.cs +++ b/Runtime/Device/IDeviceService.cs @@ -2,18 +2,16 @@ namespace GameLovers.MobileServices.Device { /// - /// Umbrella facade aggregating every device-touching service in the package. Use as a single - /// DI registration to expose the full Device subsystem; each child interface is also - /// independently registerable for testing/mocking. + /// Umbrella facade aggregating the stateful, injectable device services in the package. Use as + /// a single DI registration to expose the Device subsystem; each child interface is also + /// independently registerable for testing/mocking. Stateless global conveniences are exposed + /// directly by . /// public interface IDeviceService { /// Display safe-area events (notch, dynamic island, orientation). ISafeAreaService SafeArea { get; } - /// Toggle Screen.sleepTimeout (keep the screen awake). - IScreenWakeService ScreenWake { get; } - /// Battery level / status / low-power-mode awareness. IBatteryService Battery { get; } diff --git a/Runtime/Device/State/IScreenWakeService.cs b/Runtime/Device/State/IScreenWakeService.cs deleted file mode 100644 index d5429c6..0000000 --- a/Runtime/Device/State/IScreenWakeService.cs +++ /dev/null @@ -1,15 +0,0 @@ -// ReSharper disable once CheckNamespace -namespace GameLovers.MobileServices.Device -{ - /// - /// Controls whether the device screen should stay awake (override the OS sleep timeout). - /// - public interface IScreenWakeService - { - /// - /// When true, sets Screen.sleepTimeout to SleepTimeout.NeverSleep; - /// when false, restores SleepTimeout.SystemSetting. Idempotent. - /// - bool KeepAwake { get; set; } - } -} diff --git a/Runtime/Device/State/IScreenWakeService.cs.meta b/Runtime/Device/State/IScreenWakeService.cs.meta deleted file mode 100644 index 08650cf..0000000 --- a/Runtime/Device/State/IScreenWakeService.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: bcda1d4b4808e4808ba9b6fd7e980d99 \ No newline at end of file diff --git a/Runtime/Device/State/ScreenWakeService.cs b/Runtime/Device/State/ScreenWakeService.cs deleted file mode 100644 index e6ed370..0000000 --- a/Runtime/Device/State/ScreenWakeService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using UnityEngine; - -// ReSharper disable once CheckNamespace -namespace GameLovers.MobileServices.Device -{ - /// - public sealed class ScreenWakeService : IScreenWakeService - { - /// - public bool KeepAwake - { - get => Screen.sleepTimeout == SleepTimeout.NeverSleep; - set => Screen.sleepTimeout = value ? SleepTimeout.NeverSleep : SleepTimeout.SystemSetting; - } - } -} diff --git a/Runtime/Device/State/ScreenWakeService.cs.meta b/Runtime/Device/State/ScreenWakeService.cs.meta deleted file mode 100644 index 88e2b1b..0000000 --- a/Runtime/Device/State/ScreenWakeService.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f4ef98007e9d54bd2acaddde0c165c80 \ No newline at end of file diff --git a/Runtime/IMobileService.cs b/Runtime/IMobileService.cs index 4be099e..cd3eada 100644 --- a/Runtime/IMobileService.cs +++ b/Runtime/IMobileService.cs @@ -28,7 +28,7 @@ public interface IMobileService IHapticsService Haptics { get; } /// - /// Device sub-services (safe area, screen wake, battery, audio session, permissions, ATT, deep link). + /// Device sub-services (safe area, battery, audio session, permissions, ATT, deep link). /// IDeviceService Device { get; } } diff --git a/Runtime/NativeUi/INativeUiService.cs b/Runtime/NativeUi/INativeUiService.cs index d13804a..38047e8 100644 --- a/Runtime/NativeUi/INativeUiService.cs +++ b/Runtime/NativeUi/INativeUiService.cs @@ -12,6 +12,17 @@ public interface INativeUiService /// void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons); + /// + void ShowAlertPopUp( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons); + + /// + void DismissAlertPopUp(); + /// void ShowToastMessage(string message, bool isLongDuration); @@ -32,6 +43,18 @@ public sealed class NativeUiServiceInstance : INativeUiService public void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons) => NativeUiService.ShowAlertPopUp(isAlertSheet, title, message, buttons); + /// + public void ShowAlertPopUp( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons) + => NativeUiService.ShowAlertPopUp(isAlertSheet, isDismissible, title, message, buttons); + + /// + public void DismissAlertPopUp() => NativeUiService.DismissAlertPopUp(); + /// public void ShowToastMessage(string message, bool isLongDuration) => NativeUiService.ShowToastMessage(message, isLongDuration); diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs index 1aa6a52..a8b5c45 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -1,4 +1,7 @@ using System; +#if UNITY_ANDROID +using System.Collections.Generic; +#endif using UnityEngine; // ReSharper disable once CheckNamespace @@ -35,13 +38,14 @@ public static class NativeUiService internal delegate void AlertButtonDelegate(string buttonText); private static AlertButton[] _currentButtons; +#elif UNITY_ANDROID + private static readonly List _currentAndroidCallbacks = new List(); + private static AndroidJavaObject _currentAndroidAlert; #endif #if UNITY_EDITOR - // Editor-only override hook consumed by EditorPlatformSimulator so the Device Simulator can model - // the real "shown when requested" review flow in the editor (mirroring the Permissions / ATT - // EditorRequest*Override hooks). When set, RequestReview routes here instead of logging; when - // unset, the editor branch keeps its plain Debug.Log no-op. Player builds carry none of this. + internal static Action EditorShowAlertOverride; + internal static Action EditorDismissAlertOverride; internal static System.Action EditorRequestReviewOverride; #endif @@ -55,11 +59,37 @@ public static class NativeUiService /// Thrown if the current platform is not iOS nor Android /// public static void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons) + => ShowAlertPopUp(isAlertSheet, true, title, message, buttons); + + /// + /// Shows an alert native OS message popup and controls whether it can be dismissed without + /// choosing a button. + /// + /// + /// 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. + /// + public static void ShowAlertPopUp( + bool isAlertSheet, + bool isDismissible, + string title, + string message, + params AlertButton[] buttons) { + ValidateAlert(isAlertSheet, isDismissible, buttons); + #if UNITY_EDITOR - Debug.Log($"Show Alert Pop Up is not available in the editor and was triggered with: {title} - {message}"); + 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 ?? throw new ArgumentException("The buttons count must be higher than zero"); + _currentButtons = buttons; var buttonsText = new string[buttons.Length]; var buttonsStyle = new int[buttons.Length]; @@ -70,24 +100,16 @@ public static void ShowAlertPopUp(bool isAlertSheet, string title, string messag buttonsStyle[i] = (int) buttons[i].Style; } - AlertMessage(isAlertSheet, title, message, buttonsText, buttonsStyle, buttons.Length, AlertButtonCallback); + AlertMessage( + isAlertSheet, + title, + message, + buttonsText, + buttonsStyle, + buttons.Length, + AlertButtonCallback); #elif UNITY_ANDROID - using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) - using (var unityActivity = unityPlayer.GetStatic("currentActivity")) - using (var alertDialogBuilder = new AndroidJavaObject("android.app.AlertDialog$Builder", unityActivity)) - using (var alertDialog = alertDialogBuilder.Call("create")) - { - alertDialog.Call("setTitle", title); - alertDialog.Call("setMessage", message); - - for (var i = 0; i < buttons.Length; i++) - { - alertDialog.Call("setButton", ConvertToAndroidStyle(buttons[i].Style), - buttons[i].Text, new AndroidButtonCallback(buttons[i].Callback)); - } - - alertDialog.Call("show"); - } + ShowAlertAndroid(isDismissible, title, message, buttons); #else throw new SystemException("Show an alert Pop Up is only available for iOS and Android platforms"); #endif @@ -124,6 +146,21 @@ public static void ShowToastMessage(string message, bool isLongDuration) #endif } + /// + /// Dismisses the currently presented alert without invoking a button callback. + /// + public static void DismissAlertPopUp() + { +#if UNITY_EDITOR + EditorDismissAlertOverride?.Invoke(); +#elif UNITY_IOS + _currentButtons = null; + DismissAlert(); +#elif UNITY_ANDROID + DismissAlertAndroid(); +#endif + } + /// /// Requests an OS-mediated app rating prompt. iOS uses SKStoreReviewController; Android uses /// the Play In-App Review API. Both platforms throttle requests internally, so calling this @@ -179,7 +216,113 @@ public static void Share(string text, string url = null, string imagePath = null #endif } + private static void ValidateAlert(bool isAlertSheet, bool isDismissible, AlertButton[] buttons) + { + if (!isDismissible && isAlertSheet) + { + throw new ArgumentException("A non-dismissible alert cannot use action-sheet style.", nameof(isAlertSheet)); + } + if (buttons == null || buttons.Length == 0 || buttons.Length > 3) + { + throw new ArgumentException("Alerts require between one and three buttons.", nameof(buttons)); + } + + for (var i = 0; i < buttons.Length; i++) + { + if (string.IsNullOrWhiteSpace(buttons[i].Text)) + { + throw new ArgumentException("Alert button text cannot be empty.", nameof(buttons)); + } + + for (var j = i + 1; j < buttons.Length; j++) + { + if (buttons[i].Text == buttons[j].Text) + { + throw new ArgumentException("Alert button text must be unique.", nameof(buttons)); + } + if (buttons[i].Style == buttons[j].Style) + { + throw new ArgumentException("Alert button styles must be unique.", nameof(buttons)); + } + } + } + } + #if UNITY_ANDROID + private static void DismissAlertAndroid() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + var activity = unityPlayer.GetStatic("currentActivity"); + activity.Call("runOnUiThread", new AndroidJavaRunnable(() => + { + using (activity) + DismissCurrentAndroidAlert(); + })); + } + + private static void ShowAlertAndroid( + bool isDismissible, + string title, + string message, + AlertButton[] buttons) + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + var activity = unityPlayer.GetStatic("currentActivity"); + activity.Call("runOnUiThread", new AndroidJavaRunnable(() => + { + using (activity) + using (var alertDialogBuilder = new AndroidJavaObject("android.app.AlertDialog$Builder", activity)) + { + DismissCurrentAndroidAlert(); + + var alertDialog = alertDialogBuilder.Call("create"); + _currentAndroidAlert = alertDialog; + alertDialog.Call("setTitle", title); + alertDialog.Call("setMessage", message); + alertDialog.Call("setCancelable", isDismissible); + alertDialog.Call("setCanceledOnTouchOutside", isDismissible); + + for (var i = 0; i < buttons.Length; i++) + { + var callback = new AndroidButtonCallback(buttons[i].Callback); + _currentAndroidCallbacks.Add(callback); + alertDialog.Call( + "setButton", + ConvertToAndroidStyle(buttons[i].Style), + buttons[i].Text, + callback); + } + + var dismissCallback = new AndroidDismissCallback(); + _currentAndroidCallbacks.Add(dismissCallback); + alertDialog.Call("setOnDismissListener", dismissCallback); + alertDialog.Call("show"); + } + })); + } + + private static void DismissCurrentAndroidAlert() + { + var alert = _currentAndroidAlert; + _currentAndroidAlert = null; + _currentAndroidCallbacks.Clear(); + if (alert == null) + { + return; + } + + alert.Call("dismiss"); + alert.Dispose(); + } + + private static void ReleaseCurrentAndroidAlert() + { + var alert = _currentAndroidAlert; + _currentAndroidAlert = null; + _currentAndroidCallbacks.Clear(); + alert?.Dispose(); + } + private static void RequestReviewAndroid() { try @@ -285,6 +428,9 @@ public void onComplete(AndroidJavaObject task) private static extern void AlertMessage(bool isSheet, string title, string message, string[] buttonsText, int[] buttonsStyle, int buttonsLength, AlertButtonDelegate alertButtonCallback); + [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversDismissAlert")] + private static extern void DismissAlert(); + [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversToastMessage")] private static extern void ToastMessage(string message, bool isLongDuration); @@ -297,12 +443,14 @@ private static extern void AlertMessage(bool isSheet, string title, string messa [AOT.MonoPInvokeCallback(typeof(AlertButtonDelegate))] private static void AlertButtonCallback(string buttonText) { - if (_currentButtons == null) + var buttons = _currentButtons; + _currentButtons = null; + if (buttons == null) { return; } - foreach (var button in _currentButtons) + foreach (var button in buttons) { if (button.Text == buttonText) { @@ -326,7 +474,20 @@ public void onClick(AndroidJavaObject dialog, int which) { dialog.Call("dismiss"); - _callback(); + _callback?.Invoke(); + } + } + + private class AndroidDismissCallback : AndroidJavaProxy + { + public AndroidDismissCallback() : base("android.content.DialogInterface$OnDismissListener") + { + } + + // ReSharper disable once InconsistentNaming + public void onDismiss(AndroidJavaObject dialog) + { + ReleaseCurrentAndroidAlert(); } } diff --git a/Samples~/MobileServicesSamples/MobileServicesPlayground/MobileServicesPlaygroundUI.cs b/Samples~/MobileServicesSamples/MobileServicesPlayground/MobileServicesPlaygroundUI.cs index c5f6e83..60f1b6f 100644 --- a/Samples~/MobileServicesSamples/MobileServicesPlayground/MobileServicesPlaygroundUI.cs +++ b/Samples~/MobileServicesSamples/MobileServicesPlayground/MobileServicesPlaygroundUI.cs @@ -52,7 +52,7 @@ private void Update() new SampleStatusEntry("Battery level", batteryLevel), new SampleStatusEntry("Battery status", _device.Battery.Status), new SampleStatusEntry("Low-power mode", SampleStatusFormatter.YesNo(_device.Battery.IsLowPowerMode)), - new SampleStatusEntry("Keep awake", SampleStatusFormatter.YesNo(_device.ScreenWake.KeepAwake)), + new SampleStatusEntry("Keep awake", SampleStatusFormatter.YesNo(DeviceService.KeepAwake)), new SampleStatusEntry("ATT status", _device.Att.CurrentStatus)); } if (_safeAreaStatus != null) @@ -149,8 +149,8 @@ private void BindButtons(VisualElement root) }); root.Q