diff --git a/.gitignore b/.gitignore
index 2bbf2b4..0c49860 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,6 @@ crashlytics-build.properties
 
 
 
+
+# Tests audit history (unity-tests-audit skill -- local developer state, never committed)
+.audit-history.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..bd8e74a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,210 @@
+# 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**: 6000.0+
+- **Dependencies** (see `package.json`)
+  - `com.unity.mobile.notifications` (**2.3.0**)
+  - `com.unity.inputsystem` (**1.11.0**)
+
+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** (`MobileServicesConfig.IncludePlayReviewDependency`, default ON, via `IPostGenerateGradleAndroidProject` — see §2 build postprocessor), so consumers need zero manual `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`
+- **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<PermissionStatus>` (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", "<id>:<status>")` 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<GLLocationDelegate *>` 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 (heads-up **preview only**), 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/channels/pending/cancel were removed for the same reason** (the panel's `MobileNotificationService` was a throwaway instance disconnected from the game, duplicating the `NotificationsScheduler` sample); only the edit-mode heads-up banner **preview** mock remains. 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.<AppPermission>` / `…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 `<locale>.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 `0`).
+- **`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.
+- **`MobileServicesBuildPostprocessor`** (`Editor/Build/MobileServicesBuildPostprocessor.cs`): implements `IPostprocessBuildWithReport` **and (under `#if UNITY_ANDROID`) `IPostGenerateGradleAndroidProject`**. iOS path mutates the post-build Xcode project via `PlistDocument` + `ProjectCapabilityManager` (entitlements file `GameLoversMobileServices.entitlements`). **Localization**: the base/`en` usage + ATT descriptions go into the `Info.plist` root; for every non-default `LocaleEntry` it writes a `<locale>.lproj/InfoPlist.strings` file, sets `CFBundleLocalizations` (+ `CFBundleDevelopmentRegion`), and registers each `.strings` file on the main target via `pbx.AddFile`/`AddFileToBuild` (the in-memory `pbx` is written back BEFORE constructing `ProjectCapabilityManager`, which re-reads the file — otherwise capability writes would clobber the registration). Android manifest path patches `Assets/Plugins/Android/mainTemplate.xml` with the configured `<uses-permission>` entries + share-chooser `<queries>` block. Android gradle path (`OnPostGenerateGradleAndroidProject`) auto-injects `implementation '<PlayReviewDependencyCoordinate>'` into the generated module `build.gradle` so `RequestReview()` works zero-config — **conflict-safe**: scans every `.gradle` in the generated project and skips if `com.google.android.play:review` is already declared by any source (hand-written gradle / EDM4U / another SDK). Idempotent on re-runs. **Fail-fast** validation throws `BuildFailedException` listing every missing usage description (no soft/placeholder mode — fill the copy or use the kill-switch). `callbackOrder` is hardcoded `0`. **Escape hatch**: `ManageNativeBuildManually` short-circuits ALL mutation (and the iOS validation) for teams that configure the native build themselves.
+
+### Samples (`Samples~/`)
+- Four code-only samples.
+- `Samples~/MobileServicesPlayground/` — kitchen-sink runtime-built canvas covering every subsystem. Sample-only types in namespace `GameLovers.MobileServices.Samples.MobileServicesPlayground`.
+- `Samples~/HapticsPalette/` — designer iteration tool. Namespace `GameLovers.MobileServices.Samples.HapticsPalette`.
+- `Samples~/NotificationsScheduler/` — lifecycle demo. Namespace `GameLovers.MobileServices.Samples.NotificationsScheduler`.
+- `Samples~/DeepLinkRouter/` — `IDeepLinkRouter.MapRoute` pattern demo. Namespace `GameLovers.MobileServices.Samples.DeepLinkRouter`.
+- **Code-only sample policy**: divergence from peer `com.gamelovers.services` / `com.gamelovers.uiservice` which ship `.unity` + `.prefab` files with hand-authored deterministic GUIDs. Mobile samples build their UI at runtime via legacy `UnityEngine.UI` — zero asset dependencies, no `.meta` GUIDs to maintain, easy diff. The trade-off is no built-in scene hierarchy or prefab structure for the user to inspect; this is acceptable for the mobile surface (most behaviour is fired by buttons, not configured by serialised state).
+- `Samples~/README.md` is the index; per-sample `README.md` documents setup + the sample-only types contract.
+- `package.json` carries a `samples[]` block — adding a new sample requires updates in lockstep across `package.json`, `Samples~/README.md`, the per-sample `README.md`, and the matching `AGENTS.md` row (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.<Subsystem>`).
+- **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/Build/`. 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`. (The standalone Explorer window + its `Tabs/`/`Windows/` folders were removed in the 1.0.0 consolidation onto the Device Simulator plugin.) Honour the workspace `UnityEditor.Editor` namespace-collision rule for any Unity inspector base classes (qualify as `UnityEditor.Editor`).
+- **Native bridges live in `Plugins/iOS/<Subsystem>.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. **Editor tooling is not automated-tested** — types under `Editor/` (`MobileServicesDeviceSimulatorPlugin`, `MobileSimulatorRuntimeOverlay`, `MobileSimulatorState`, `MockBuilders`, `EditorPlatformSimulator`, `MobileServicesConfig` / `MobileServicesConfigEditor` / `MobileServicesConfigMenuItems`, `MobileServicesScanner`, `MobileServicesBuildPostprocessor`) are validated by manual editor smoke + on-device builds; see `Tests/AGENTS.md` §1 and §9 for the policy and rationale. Runtime tests do NOT mirror the runtime folder structure — group by feature, not by source path.
+
+## 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.
+- **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; it handles this automatically in `OnEnable`/`OnDisable`.
+  - 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 still need to be added by the consumer's `AndroidManifest.xml` / `mainTemplate.xml`.
+- **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)
+- `MobileServicesConfig` schema (`[SerializeField]` rows on the SO asset — current set: per-locale usage descriptions, per-locale ATT usage, capability toggles, Android manifest toggles, `IncludePlayReviewDependency` + `PlayReviewDependencyCoordinate`, `ManageNativeBuildManually`), the `MobileServicesConfigEditor` Inspector layout, the `Select Mobile Services Config` menu item, the `MobileServicesConfig.Instance` / `GetOrCreateAsset` locator, project scanner detection rules, build postprocessor mutation logic (Info.plist keys, per-locale `InfoPlist.strings` emission + `CFBundleLocalizations`, entitlements capabilities, Android manifest entries, 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 `Samples~/README.md`, per-sample `README.md`, `package.json` `samples[]` block, AND the AGENTS.md Samples row, in lockstep
diff --git a/Third Party Notices.md.meta b/AGENTS.md.meta
similarity index 75%
rename from Third Party Notices.md.meta
rename to AGENTS.md.meta
index bc950b7..bdf1846 100644
--- a/Third Party Notices.md.meta	
+++ b/AGENTS.md.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 03bee7ce4f04a414999552da5575a7ee
+guid: ae4ccf446e77f43afb0abc7ed3a02553
 TextScriptImporter:
   externalObjects: {}
   userData: 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61c8aa8..97078e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,59 +1,102 @@
-# Changelog
-All notable changes to this package will be documented in this file.
-
-The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
-and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
-
-## [0.2.5] - 2021-01-15
-
-**Fixed**:
-- Fixed crash when showing Alert buttons on the editor
-
-## [0.2.4] - 2020-09-24
-
-**Fixed**:
-- Fixed compiler warning for not using native code
-
-## [0.2.3] - 2020-08-12
-
-**Fixed**:
-- Fixed build errors
-
-## [0.2.2] - 2020-08-12
-
-**Fixed**:
-- Fixed UI working on the editor
-
-## [0.2.1] - 2020-08-03
-
-**Fixed**:
-- Fixed build error
-
-## [0.2.0] - 2020-08-02
-
-**Changed**:
-- Removed the show rate the game pop up. From now one use the Unity direct message or Google Play package
-
-## [0.1.4] - 2020-08-02
-
-**Fixed**:
-- Package now working properly on Android
-
-## [0.1.3] - 2020-08-02
-
-**Fixed**:
-- Package now working properly on Android
-
-## [0.1.2] - 2020-08-02
-
-**Fixed**:
-- Package now working properly on Android
-
-## [0.1.1] - 2020-07-31
-
-**Fixed**:
-- Package now working properly on iOS
-
-## [0.1.0] - 2020-07-30
-
-- Initial submission for package distribution
+# Changelog
+
+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.0.0] - 2026-07-04
+
+### Added
+- Initial release of consolidated **Mobile Services** package.
+- **Native UI**: Alerts, action sheets, and toasts for iOS/Android, plus OS review request and a share sheet (text / URL / image).
+- **Notifications**: Local notification scheduling and management (channels, queueing, fluent `Schedule().In(...).Title(...).Body(...).Channel(...).Send()` builder).
+- **Gestures**: Advanced swipe detection with velocity and consistency tracking.
+- **iOS Audio Session**: Override the iOS silent switch so audio keeps playing.
+- **Haptics**: Zero-dependency cross-platform haptic feedback with 9 presets, custom intensity, and time-bounded looping.
+- **Device**: Umbrella facade over `SafeArea`, `ScreenWake`, `Battery` (with LPM awareness), `AudioSession`, `Permissions`, `Att`, and `DeepLink` sub-services.
+- **Permissions**: Unified iOS+Android runtime permissions (Camera, Microphone, Location, Photo Library, Notifications) with `Task`-based async and a multi-permission `RequestAsync(params AppPermission[])` overload.
+- **App Tracking Transparency**: iOS 14.5+ `ATTrackingManager` bridge with zero dependency on `com.unity.ads.ios-support`.
+- **Deep Links**: `Application.deepLinkActivated` wrapper with cold-start link queueing for the first subscriber.
+- **Deep Link Router**: Path-pattern routing over `IDeepLinkService` with captured params (`/promo/:id`).
+- **Mobile Service umbrella**: `IMobileService` — single DI registration aggregating `NativeUi` / `Notifications` / `Haptics` / `Device`.
+- **Native UI instance interface**: `INativeUiService` + `NativeUiServiceInstance` forwarder for mockable consumer code.
+- **Device Simulator Plugin**: A `DeviceSimulatorPlugin` embedded in Unity's Device Simulator window that drives platform-shaped native-UI mocks into the simulated phone screen (edit + play), with live diagnostics and a per-preset haptic envelope graph.
+- **Mobile Services Config asset**: Editor `ScriptableObject` (open via `Tools > GameLovers > Mobile Services > Select Mobile Services Config`) for per-permission **localized** usage descriptions, capability toggles, Android manifest opt-ins, and the Play In-App Review Gradle auto-injection.
+- **Build Postprocessor**: Auto-injects iOS `Info.plist` usage descriptions (+ per-locale `<locale>.lproj/InfoPlist.strings` for device-language localization), entitlements, Android manifest entries, and the Play In-App Review Gradle dependency; fail-fast validation lists every missing key.
+- **Samples**: Four code-only samples — `MobileServicesPlayground`, `HapticsPalette`, `NotificationsScheduler`, `DeepLinkRouter`.
+- **Docs**: Per-subsystem deep-dive references under `docs/` plus editor-tooling guides for the Device Simulator panel and build pipeline.
+
+### Changed
+- Refactored all namespaces to `GameLovers.MobileServices.*`.
+- Updated assembly definition to `GameLovers.MobileServices`.
+- Updated dependencies to target Unity 6 (6000.0+).
+
+### Removed
+- Removed legacy tap detection — use Unity Input System's `TapInteraction`.
+- Removed gamepad input management — out of scope for mobile services; configure it via the Input System directly.
+
+### Fixed
+- `Runtime/GameLovers.MobileServices.asmdef` was the only asmdef in the repo missing the `noEngineReferences` key — added (`false`, matching every other asmdef in the package family).
+
+### Migration
+This package consolidates three previously separate packages:
+- `com.gamelovers.nativeui` (v0.2.5) -> `GameLovers.MobileServices.NativeUi`
+- `com.gamelovers.notificationservice` (v0.1.7) -> `GameLovers.MobileServices.Notifications`
+- `com.gamelovers.inputextensions` (v0.1.0-preview.4, swipe detection only) -> `GameLovers.MobileServices.Gestures`
+- `AlertButtonStyle.Positive` -> `AlertButtonStyle.Destructive`; `AlertButtonStyle.Negative` -> `AlertButtonStyle.Cancel`. Underlying iOS/Android platform mapping is unchanged; pure rename for iOS-native vocabulary.
+
+## [0.2.5] - 2021-01-15
+
+**Fixed**:
+- Fixed crash when showing Alert buttons on the editor
+
+## [0.2.4] - 2020-09-24
+
+**Fixed**:
+- Fixed compiler warning for not using native code
+
+## [0.2.3] - 2020-08-12
+
+**Fixed**:
+- Fixed build errors
+
+## [0.2.2] - 2020-08-12
+
+**Fixed**:
+- Fixed UI working on the editor
+
+## [0.2.1] - 2020-08-03
+
+**Fixed**:
+- Fixed build error
+
+## [0.2.0] - 2020-08-02
+
+**Changed**:
+- Removed the show rate the game pop up. From now one use the Unity direct message or Google Play package
+
+## [0.1.4] - 2020-08-02
+
+**Fixed**:
+- Package now working properly on Android
+
+## [0.1.3] - 2020-08-02
+
+**Fixed**:
+- Package now working properly on Android
+
+## [0.1.2] - 2020-08-02
+
+**Fixed**:
+- Package now working properly on Android
+
+## [0.1.1] - 2020-07-31
+
+**Fixed**:
+- Package now working properly on iOS
+
+## [0.1.0] - 2020-07-30
+
+- Initial submission for package distribution
+
diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta
index 1555c8e..43f1a04 100644
--- a/CHANGELOG.md.meta
+++ b/CHANGELOG.md.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 0dcff2ea08a2b4907aa04df5cb83b64b
+guid: 1dc2da23a500148dc86308c8d1b1824d
 TextScriptImporter:
   externalObjects: {}
   userData: 
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..12e6eb0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,12 @@
+# Claude Code Guide — GameLovers Mobile Services
+
+This package's contributor/agent guide lives in `AGENTS.md`.
+Claude Code will automatically import it below.
+
+@AGENTS.md
+
+## Claude-Specific Notes
+
+- Treat `AGENTS.md` as the source of truth.
+- If anything in this file appears to conflict with `AGENTS.md`, prefer `AGENTS.md`.
+- For user-facing usage, see `README.md`.
diff --git a/CLAUDE.md.meta b/CLAUDE.md.meta
new file mode 100644
index 0000000..3365d86
--- /dev/null
+++ b/CLAUDE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 4e365a761897c442bbf2fd33217ca266
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor.meta b/Editor.meta
new file mode 100644
index 0000000..5fd5431
--- /dev/null
+++ b/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: cd8074287e5284c3ea8aa48ce80ef4a0
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Build.meta b/Editor/Build.meta
new file mode 100644
index 0000000..9c78b3c
--- /dev/null
+++ b/Editor/Build.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1a34ffea8e11e4ae2bd74c56b40823fe
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Build/MobileServicesBuildPostprocessor.cs b/Editor/Build/MobileServicesBuildPostprocessor.cs
new file mode 100644
index 0000000..0d513bd
--- /dev/null
+++ b/Editor/Build/MobileServicesBuildPostprocessor.cs
@@ -0,0 +1,430 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Editor.Settings;
+using UnityEditor;
+using UnityEditor.Build;
+using UnityEditor.Build.Reporting;
+using UnityEngine;
+
+#if UNITY_IOS
+using UnityEditor.iOS.Xcode;
+#endif
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Build
+{
+	/// <summary>
+	/// Validates iOS usage descriptions and mutates the post-build Xcode project / Android
+	/// <c>mainTemplate.xml</c> with the keys + capabilities configured in
+	/// <see cref="MobileServicesConfig"/>. See <c>docs/build-pipeline.md</c> for details.
+	/// </summary>
+	public sealed class MobileServicesBuildPostprocessor : IPostprocessBuildWithReport
+#if UNITY_ANDROID
+		, UnityEditor.Android.IPostGenerateGradleAndroidProject
+#endif
+	{
+		public int callbackOrder => 0;
+
+		public void OnPostprocessBuild(BuildReport report)
+		{
+			if (report == null)
+			{
+				return;
+			}
+
+			if (MobileServicesConfig.Instance.ManageNativeBuildManually)
+			{
+				Debug.Log("[GameLovers.MobileServices] 'Manage Native Build Manually' is enabled — skipping all plist / entitlements / manifest mutation.");
+				return;
+			}
+
+			switch (report.summary.platform)
+			{
+				case BuildTarget.iOS:
+					PostprocessIos(report);
+					break;
+				case BuildTarget.Android:
+					PostprocessAndroid();
+					break;
+			}
+		}
+
+		// ---- iOS ----
+
+		private void PostprocessIos(BuildReport report)
+		{
+			var settings = MobileServicesConfig.Instance;
+			var scan = MobileServicesScanner.Scan();
+
+			var missing = settings.GetMissingUsageDescriptions(scan.ReferencedPermissions);
+			var attMissing = scan.UsesAtt && string.IsNullOrWhiteSpace(settings.GetAttUsageDescriptionEn());
+
+			if (missing.Count > 0 || attMissing)
+			{
+				var sb = new StringBuilder();
+				sb.AppendLine("[GameLovers.MobileServices] iOS build failed because the following Info.plist keys are required by referenced services but have empty usage descriptions:");
+				foreach (var p in missing)
+				{
+					sb.AppendLine($"  - {MobileServicesConfig.GetIosUsageKey(p)} (for AppPermission.{p})");
+				}
+				if (attMissing)
+				{
+					sb.AppendLine("  - NSUserTrackingUsageDescription (App Tracking Transparency capability is enabled)");
+				}
+				sb.AppendLine();
+				sb.AppendLine("Fix: open Tools > GameLovers > Mobile Services > Select Mobile Services Config and fill in the missing usage descriptions");
+				sb.AppendLine("(use the 'Fill missing English descriptions with suggested copy' button for a quick start), or enable 'Manage Native Build Manually' if you manage Info.plist yourself.");
+				throw new BuildFailedException(sb.ToString());
+			}
+
+#if UNITY_IOS
+			InjectIosBuild(report, settings, scan);
+#else
+			Debug.Log("[GameLovers.MobileServices] iOS Xcode project mutation skipped — UNITY_IOS not defined on this build host (validator only ran).");
+#endif
+		}
+
+#if UNITY_IOS
+		private static void InjectIosBuild(BuildReport report, MobileServicesConfig settings, ProjectScanResult scan)
+		{
+			var buildPath = report.summary.outputPath;
+			if (string.IsNullOrEmpty(buildPath) || !Directory.Exists(buildPath))
+			{
+				Debug.LogWarning("[GameLovers.MobileServices] Build output path missing — skipping Xcode project mutation.");
+				return;
+			}
+
+			// Info.plist
+			var plistPath = Path.Combine(buildPath, "Info.plist");
+			if (File.Exists(plistPath))
+			{
+				var plist = new PlistDocument();
+				plist.ReadFromFile(plistPath);
+				var rootDict = plist.root;
+
+				foreach (var row in settings.PermissionDescriptions)
+				{
+					var key = MobileServicesConfig.GetIosUsageKey(row.Permission);
+					if (key == null) continue;
+					var en = settings.GetUsageDescriptionEn(row.Permission);
+					if (string.IsNullOrWhiteSpace(en)) continue;
+					rootDict.SetString(key, en);
+				}
+
+				if (settings.Capabilities.AppTracking)
+				{
+					var attCopy = settings.GetAttUsageDescriptionEn();
+					if (!string.IsNullOrWhiteSpace(attCopy))
+					{
+						rootDict.SetString("NSUserTrackingUsageDescription", attCopy);
+					}
+				}
+
+				// Declare the supported localizations so iOS knows to consult the <locale>.lproj/
+				// InfoPlist.strings files emitted below (the base/en value above is the fallback).
+				var localesForPlist = settings.GetNonDefaultLocaleCodes();
+				if (localesForPlist.Count > 0)
+				{
+					if (!rootDict.values.ContainsKey("CFBundleDevelopmentRegion"))
+					{
+						rootDict.SetString("CFBundleDevelopmentRegion", MobileServicesConfig.DefaultLocaleCode);
+					}
+					var localizations = rootDict.CreateArray("CFBundleLocalizations");
+					localizations.AddString(MobileServicesConfig.DefaultLocaleCode);
+					foreach (var locale in localesForPlist)
+					{
+						localizations.AddString(locale);
+					}
+				}
+
+				plist.WriteToFile(plistPath);
+			}
+
+			// PBXProject + capabilities
+			var pbxPath = PBXProject.GetPBXProjectPath(buildPath);
+			if (!File.Exists(pbxPath)) return;
+
+			var pbx = new PBXProject();
+			pbx.ReadFromFile(pbxPath);
+
+			var mainTargetGuid = pbx.GetUnityMainTargetGuid();
+			var frameworkTargetGuid = pbx.GetUnityFrameworkTargetGuid();
+			if (frameworkTargetGuid == null) frameworkTargetGuid = mainTargetGuid;
+
+			// Emit + register the localized usage descriptions BEFORE ProjectCapabilityManager re-reads
+			// the .pbxproj from disk (otherwise capability.WriteToFile() would overwrite these changes).
+			EmitLocalizedInfoPlistStrings(buildPath, settings, pbx, mainTargetGuid);
+			pbx.WriteToFile(pbxPath);
+
+			var entitlementsRelativeName = "GameLoversMobileServices.entitlements";
+			var entitlementsAbs = Path.Combine(buildPath, entitlementsRelativeName);
+			var capability = new ProjectCapabilityManager(pbxPath, entitlementsRelativeName, null, mainTargetGuid);
+
+			if (settings.Capabilities.PushNotifications)
+			{
+				capability.AddPushNotifications(true);
+			}
+			if (settings.Capabilities.AssociatedDomains && settings.Capabilities.AssociatedDomainList.Count > 0)
+			{
+				var domains = new string[settings.Capabilities.AssociatedDomainList.Count];
+				for (var i = 0; i < domains.Length; i++)
+				{
+					domains[i] = settings.Capabilities.AssociatedDomainList[i];
+				}
+				capability.AddAssociatedDomains(domains);
+			}
+
+			capability.WriteToFile();
+		}
+
+		// Writes + registers one <locale>.lproj/InfoPlist.strings per non-default locale (base/en stays in Info.plist root).
+		private static void EmitLocalizedInfoPlistStrings(string buildPath, MobileServicesConfig settings, PBXProject pbx, string mainTargetGuid)
+		{
+			var locales = settings.GetNonDefaultLocaleCodes();
+			if (locales.Count == 0)
+			{
+				return;
+			}
+
+			foreach (var locale in locales)
+			{
+				var sb = new StringBuilder();
+				sb.AppendLine("/* Auto-generated by GameLovers.MobileServices — localized usage descriptions. Do not edit. */");
+
+				var wroteAny = false;
+				foreach (var row in settings.PermissionDescriptions)
+				{
+					var key = MobileServicesConfig.GetIosUsageKey(row.Permission);
+					if (key == null) continue;
+					var value = settings.GetUsageDescription(row.Permission, locale);
+					if (string.IsNullOrWhiteSpace(value)) continue;
+					sb.AppendLine($"\"{key}\" = \"{EscapeStringsValue(value)}\";");
+					wroteAny = true;
+				}
+
+				if (settings.Capabilities.AppTracking)
+				{
+					var att = settings.GetAttUsageDescription(locale);
+					if (!string.IsNullOrWhiteSpace(att))
+					{
+						sb.AppendLine($"\"NSUserTrackingUsageDescription\" = \"{EscapeStringsValue(att)}\";");
+						wroteAny = true;
+					}
+				}
+
+				if (!wroteAny)
+				{
+					continue;
+				}
+
+				var relDir = $"{locale}.lproj";
+				Directory.CreateDirectory(Path.Combine(buildPath, relDir));
+				var relFile = $"{relDir}/InfoPlist.strings";
+				File.WriteAllText(Path.Combine(buildPath, relFile), sb.ToString());
+
+				var fileGuid = pbx.AddFile(relFile, relFile, PBXSourceTree.Source);
+				pbx.AddFileToBuild(mainTargetGuid, fileGuid);
+			}
+
+			Debug.Log($"[GameLovers.MobileServices] Emitted localized InfoPlist.strings for {locales.Count} locale(s): {string.Join(", ", locales)}.");
+		}
+
+		// Escapes a value for the .strings format ("key" = "value";).
+		private static string EscapeStringsValue(string value) =>
+			value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
+#endif
+
+		// ---- Android ----
+
+		private void PostprocessAndroid()
+		{
+			var settings = MobileServicesConfig.Instance;
+			var a = settings.AndroidManifest;
+
+			var templatePath = Path.Combine(Application.dataPath, "Plugins", "Android", "mainTemplate.xml");
+			if (!File.Exists(templatePath))
+			{
+				Debug.LogWarning($"[GameLovers.MobileServices] Android mainTemplate.xml not found at {templatePath}. Permission entries will not be auto-injected — copy Unity's default template from Player Settings > Publishing Settings > Custom Main Manifest before next build.");
+				return;
+			}
+
+			var contents = File.ReadAllText(templatePath);
+
+			var permissions = new List<string>();
+			if (a.Camera)                permissions.Add("android.permission.CAMERA");
+			if (a.RecordAudio)           permissions.Add("android.permission.RECORD_AUDIO");
+			if (a.AccessFineLocation)    permissions.Add("android.permission.ACCESS_FINE_LOCATION");
+			if (a.ReadMediaImages)       permissions.Add("android.permission.READ_MEDIA_IMAGES");
+			if (a.PostNotifications)     permissions.Add("android.permission.POST_NOTIFICATIONS");
+
+			var changed = false;
+			foreach (var perm in permissions)
+			{
+				var line = $"    <uses-permission android:name=\"{perm}\" />";
+				if (!contents.Contains($"android:name=\"{perm}\""))
+				{
+					contents = InsertBeforeApplication(contents, line);
+					changed = true;
+				}
+			}
+
+			if (a.IncludeShareQueriesBlock && !contents.Contains("ACTION_SEND"))
+			{
+				var queriesBlock = "    <queries>\n" +
+				                   "        <intent>\n" +
+				                   "            <action android:name=\"android.intent.action.SEND\" />\n" +
+				                   "            <data android:mimeType=\"*/*\" />\n" +
+				                   "        </intent>\n" +
+				                   "    </queries>";
+				contents = InsertBeforeApplication(contents, queriesBlock);
+				changed = true;
+			}
+
+			if (changed)
+			{
+				File.WriteAllText(templatePath, contents);
+				AssetDatabase.Refresh();
+				Debug.Log("[GameLovers.MobileServices] Patched Android mainTemplate.xml with configured permissions and queries.");
+			}
+		}
+
+		private static string InsertBeforeApplication(string xml, string snippet)
+		{
+			var applicationIndex = xml.IndexOf("<application");
+			if (applicationIndex < 0)
+			{
+				return xml;
+			}
+			var insertAt = xml.LastIndexOf('\n', applicationIndex);
+			if (insertAt < 0) insertAt = applicationIndex;
+			return xml.Insert(insertAt, "\n" + snippet);
+		}
+
+#if UNITY_ANDROID
+		// ---- Android Gradle dependency injection ----
+
+		private const string PlayReviewArtifactKey = "com.google.android.play:review";
+
+		/// <summary>
+		/// Auto-injects the Play In-App Review dependency into the generated Gradle project so
+		/// <c>NativeUiService.RequestReview()</c> works with zero manual setup. Idempotent and
+		/// conflict-safe: skips entirely if the dependency is already declared by ANY gradle file in
+		/// the generated project (hand-written gradle, EDM4U, another SDK), so it never double-declares
+		/// or fights a consumer's version pin.
+		/// </summary>
+		public void OnPostGenerateGradleAndroidProject(string path)
+		{
+			var settings = MobileServicesConfig.Instance;
+			if (settings.ManageNativeBuildManually || !settings.IncludePlayReviewDependency)
+			{
+				return;
+			}
+
+			if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
+			{
+				return;
+			}
+
+			string[] gradleFiles;
+			try
+			{
+				gradleFiles = Directory.GetFiles(path, "*.gradle", SearchOption.AllDirectories);
+			}
+			catch (System.Exception e)
+			{
+				Debug.LogWarning($"[GameLovers.MobileServices] Could not enumerate gradle files under '{path}': {e.Message}. Add '{settings.PlayReviewDependencyCoordinate}' manually if RequestReview() is needed.");
+				return;
+			}
+
+			// Conflict-safe skip: if any gradle file already declares the artifact (any version /
+			// source), leave the consumer's declaration untouched.
+			foreach (var file in gradleFiles)
+			{
+				if (ReadAllTextSafe(file).Contains(PlayReviewArtifactKey))
+				{
+					Debug.Log($"[GameLovers.MobileServices] '{PlayReviewArtifactKey}' is already declared in the Gradle project — skipping auto-injection.");
+					return;
+				}
+			}
+
+			var targetGradle = FindModuleBuildGradle(path, gradleFiles);
+			if (targetGradle == null)
+			{
+				Debug.LogWarning("[GameLovers.MobileServices] Could not find a module build.gradle with a dependencies block to inject the Play Review dependency. " +
+					$"Add 'implementation \"{settings.PlayReviewDependencyCoordinate}\"' manually if RequestReview() is needed, or disable 'Include Play Review dependency' in Project Settings.");
+				return;
+			}
+
+			var contents = ReadAllTextSafe(targetGradle);
+			var injected = InsertIntoDependenciesBlock(contents, $"implementation '{settings.PlayReviewDependencyCoordinate}'");
+			if (injected == null)
+			{
+				Debug.LogWarning($"[GameLovers.MobileServices] No 'dependencies {{ }}' block found in '{targetGradle}' — could not inject the Play Review dependency.");
+				return;
+			}
+
+			File.WriteAllText(targetGradle, injected);
+			Debug.Log($"[GameLovers.MobileServices] Injected '{settings.PlayReviewDependencyCoordinate}' into '{Path.GetFileName(targetGradle)}' for Play In-App Review.");
+		}
+
+		// Prefer the unityLibrary module (where Unity puts app dependencies); otherwise the first
+		// build.gradle that applies an Android plugin and has a dependencies block.
+		private static string FindModuleBuildGradle(string path, string[] gradleFiles)
+		{
+			var unityLibraryGradle = Path.Combine(path, "build.gradle");
+			if (File.Exists(unityLibraryGradle) && ReadAllTextSafe(unityLibraryGradle).Contains("dependencies"))
+			{
+				return unityLibraryGradle;
+			}
+
+			string firstWithDependencies = null;
+			foreach (var file in gradleFiles)
+			{
+				var dir = Path.GetFileName(Path.GetDirectoryName(file) ?? string.Empty);
+				var text = ReadAllTextSafe(file);
+				if (!text.Contains("dependencies"))
+				{
+					continue;
+				}
+				if (dir == "unityLibrary" && (text.Contains("com.android.library") || text.Contains("com.android.application")))
+				{
+					return file;
+				}
+				if (firstWithDependencies == null && (text.Contains("com.android.library") || text.Contains("com.android.application")))
+				{
+					firstWithDependencies = file;
+				}
+			}
+			return firstWithDependencies;
+		}
+
+		// Inserts a line at the top of the first top-level `dependencies { ... }` block. Returns null
+		// when no such block exists.
+		private static string InsertIntoDependenciesBlock(string gradle, string implementationLine)
+		{
+			const string marker = "dependencies {";
+			var index = gradle.IndexOf(marker, System.StringComparison.Ordinal);
+			if (index < 0)
+			{
+				return null;
+			}
+			var insertAt = index + marker.Length;
+			return gradle.Insert(insertAt, "\n    " + implementationLine);
+		}
+
+		private static string ReadAllTextSafe(string file)
+		{
+			try
+			{
+				return File.ReadAllText(file);
+			}
+			catch
+			{
+				return string.Empty;
+			}
+		}
+#endif
+	}
+}
diff --git a/Editor/Build/MobileServicesBuildPostprocessor.cs.meta b/Editor/Build/MobileServicesBuildPostprocessor.cs.meta
new file mode 100644
index 0000000..379df5f
--- /dev/null
+++ b/Editor/Build/MobileServicesBuildPostprocessor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cf216761cfcf94e68914c729156334f5
\ No newline at end of file
diff --git a/Editor/Explorer.meta b/Editor/Explorer.meta
new file mode 100644
index 0000000..4364c8c
--- /dev/null
+++ b/Editor/Explorer.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e266609911fa54d4ea319b07ffcb227f
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Explorer/DeviceSimulatorPanel.meta b/Editor/Explorer/DeviceSimulatorPanel.meta
new file mode 100644
index 0000000..80403e3
--- /dev/null
+++ b/Editor/Explorer/DeviceSimulatorPanel.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6830fe3ba1ad440ef92e03f38431342d
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss
new file mode 100644
index 0000000..5beec87
--- /dev/null
+++ b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss
@@ -0,0 +1,177 @@
+#mobile-services-plugin-root {
+    flex-direction: column;
+    padding: 6px;
+}
+
+/* The Device Simulator Control Panel column is narrow — wrap every label instead of clipping it. */
+#mobile-services-plugin-root Label {
+    white-space: normal;
+}
+
+.msp-header {
+    flex-direction: column;
+    margin-bottom: 6px;
+    padding-bottom: 6px;
+    border-bottom-width: 1px;
+    border-bottom-color: rgba(255, 255, 255, 0.08);
+}
+
+.msp-title {
+    -unity-font-style: bold;
+    font-size: 12px;
+    color: rgb(220, 220, 230);
+}
+
+.msp-note {
+    font-size: 10px;
+    -unity-font-style: italic;
+    color: rgba(220, 220, 230, 0.6);
+    white-space: normal;
+    margin-top: 2px;
+    margin-bottom: 4px;
+}
+
+.msp-enabled-toggle {
+    margin-top: 4px;
+    -unity-font-style: bold;
+}
+
+/* Highlighted info card — more visible than .msp-note for section-level guidance. */
+.msp-banner {
+    background-color: rgba(80, 130, 200, 0.12);
+    border-left-width: 3px;
+    border-left-color: rgba(120, 170, 240, 0.85);
+    border-top-right-radius: 3px;
+    border-bottom-right-radius: 3px;
+    padding: 6px 8px;
+    margin-top: 4px;
+    margin-bottom: 6px;
+}
+
+.msp-banner-label {
+    font-size: 10px;
+    color: rgb(205, 218, 240);
+    white-space: normal;
+}
+
+.msp-button-row {
+    flex-direction: row;
+    flex-wrap: wrap;
+}
+
+.msp-button {
+    margin: 2px;
+    padding: 4px 6px;
+    min-height: 22px;
+    white-space: normal;
+    -unity-text-align: middle-center;
+}
+
+.msp-button-danger {
+    background-color: rgba(220, 60, 60, 0.55);
+    color: white;
+    -unity-font-style: bold;
+}
+
+.msp-foldout {
+    margin-top: 4px;
+    margin-bottom: 2px;
+}
+
+/* ---- Diagnostics: section labels, rows, status pills (ported from the former Explorer USS) ---- */
+
+.tab-section-label {
+    -unity-font-style: bold;
+    font-size: 12px;
+    margin-top: 6px;
+    margin-bottom: 2px;
+    color: rgb(180, 210, 255);
+}
+
+.row {
+    flex-direction: row;
+    align-items: center;
+    padding: 3px 4px;
+    border-bottom-width: 1px;
+    border-color: rgba(255, 255, 255, 0.05);
+}
+
+.row:hover {
+    background-color: rgba(255, 255, 255, 0.05);
+}
+
+.row-label {
+    flex-grow: 1;
+    font-size: 11px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.row-value {
+    font-size: 11px;
+    color: rgb(200, 200, 200);
+    margin-right: 6px;
+    max-width: 160px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+/* ---- Haptics envelope graph ---- */
+
+.haptic-envelope-canvas {
+    height: 80px;
+    background-color: rgba(255, 255, 255, 0.04);
+    border-width: 1px;
+    border-color: rgba(255, 255, 255, 0.12);
+    border-radius: 3px;
+    margin: 4px 0;
+}
+
+.haptic-bar {
+    background-color: rgba(120, 210, 255, 0.7);
+    border-radius: 1px;
+}
+
+/* ---- Permission status pills ---- */
+
+.perm-pill-granted {
+    background-color: rgba(50, 180, 50, 0.30);
+    border-color: rgba(80, 200, 80, 0.65);
+    border-width: 1px;
+    border-radius: 6px;
+    padding: 1px 6px;
+    color: rgb(120, 240, 120);
+    font-size: 10px;
+}
+
+.perm-pill-denied {
+    background-color: rgba(200, 50, 50, 0.30);
+    border-color: rgba(220, 80, 80, 0.65);
+    border-width: 1px;
+    border-radius: 6px;
+    padding: 1px 6px;
+    color: rgb(255, 130, 130);
+    font-size: 10px;
+}
+
+.perm-pill-undetermined {
+    background-color: rgba(180, 180, 180, 0.25);
+    border-color: rgba(200, 200, 200, 0.55);
+    border-width: 1px;
+    border-radius: 6px;
+    padding: 1px 6px;
+    color: rgb(220, 220, 220);
+    font-size: 10px;
+}
+
+.perm-pill-restricted {
+    background-color: rgba(200, 130, 0, 0.30);
+    border-color: rgba(220, 160, 0, 0.55);
+    border-width: 1px;
+    border-radius: 6px;
+    padding: 1px 6px;
+    color: rgb(240, 200, 90);
+    font-size: 10px;
+}
diff --git a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss.meta b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss.meta
new file mode 100644
index 0000000..fa84091
--- /dev/null
+++ b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: 142761173930a4634a32491b90ab89b1
+ScriptedImporter:
+  internalIDToNameTable: []
+  externalObjects: {}
+  serializedVersion: 2
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
+  script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+  disableValidation: 0
+  unsupportedSelectorAction: 0
diff --git a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs
new file mode 100644
index 0000000..7cc32ec
--- /dev/null
+++ b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs
@@ -0,0 +1,916 @@
+using System;
+using System.Collections.Generic;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Editor.Explorer.Overlays;
+using GameLovers.MobileServices.Editor.Simulation;
+using GameLovers.MobileServices.Gestures;
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using GameLovers.MobileServices.NativeUi;
+using UnityEditor;
+using UnityEditor.DeviceSimulation;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Explorer.DeviceSimulatorPanel
+{
+	/// <summary>
+	/// The single Mobile Services editor surface: a <see cref="DeviceSimulatorPlugin"/> that embeds
+	/// the controls + live diagnostics + haptic envelope graph inside Unity's Device Simulator window
+	/// (Window &gt; General &gt; Device Simulator), driving the in-Game-view
+	/// <c>MobileSimulatorRuntimeOverlay</c> via the <see cref="MobileSimulatorState"/> broker so the
+	/// mocks render right inside the simulated phone screen (edit mode included).
+	/// </summary>
+	/// <remarks>
+	/// Replaces the former standalone Explorer + Simulator windows. The overlay is kept alive for as
+	/// long as this panel is open (<see cref="MobileSimulatorRuntimeOverlay.NotifyPluginActive"/>),
+	/// so anything fired here paints immediately, in edit or play mode. The controls that drive live
+	/// state (Permission / ATT / connectivity state, gestures) require Play mode; the mock previews and
+	/// the haptic envelope graph render in edit mode. The platform skin auto-syncs from
+	/// the selected device profile via <c>Application.platform</c> (spoofed by the Device Simulator),
+	/// which is robust across Unity 6 minor versions where <c>DeviceSimulator.deviceChanged</c> varies.
+	/// </remarks>
+	internal sealed class MobileServicesDeviceSimulatorPlugin : DeviceSimulatorPlugin
+	{
+		private const string DefaultAlertTitle = "Delete Save?";
+		private const string DefaultAlertMessage = "This action cannot be undone.";
+		private const string DefaultToastMessage = "Item Collected!";
+		private const string DefaultShareText = "Check out my high score!";
+		private const string DefaultShareUrl = "https://example.com/game";
+		private const string DefaultNotificationTitle = "Reward ready!";
+		private const string DefaultNotificationBody = "Your daily quest reward is waiting.";
+		private const string PlayModeTooltip = "Requires Play mode — this spawns a runtime host or reads/drives live device state.";
+		private const string DisabledTooltip = "Disabled in edit mode — this reads/drives live device state.";
+
+		// ---- User-facing panel copy (edit here to tweak wording) ----
+		private const string HeaderNote = "Fires native-UI mocks into the simulated phone screen (edit + play). Live diagnostics below need Play mode.";
+		private const string PlayModeBannerText = "Some controls are disabled until you enter Play mode, they spawn runtime hosts or read/drive live device state. Mock previews, haptic presets, and the envelope graph work in edit mode.";
+		private const string HapticsNote = "Haptics fire only on a physical device. Select a preset to inspect its vibration envelope (the editor calibration cue).";
+		private const string NotificationsNote = "Preview the heads-up banner (works in edit mode). For live scheduling / channels / queueing / pending, use the NotificationsScheduler sample — it drives the game's own service.";
+		private const string PermissionsInfo = "The first runtime RequestAsync() on a NotDetermined permission shows the OS prompt in the overlay; afterwards it is cached.";
+		private const string AttInfo = "The first runtime RequestAuthorizationAsync() on a NotDetermined status shows the ATT prompt in the overlay (iOS skin only); afterwards it is cached.";
+		private const float EnvelopePlotHeight = 110f;
+		private const float EnvelopeYAxisWidth = 30f;
+
+		public override string title => "Mobile Services";
+
+		private readonly List<VisualElement> _playModeControls = new List<VisualElement>();
+		private readonly List<VisualElement> _editModeBanners = new List<VisualElement>();
+
+		private Toggle _enabledToggle;
+		private VisualElement _sectionsContainer;
+
+		// ---- Held service instances ----
+		private readonly PermissionsService _permissions = new PermissionsService();
+		private readonly AttService _att = new AttService();
+
+		// ---- Native UI authoring fields ----
+		private TextField _alertTitle;
+		private TextField _alertMessage;
+		private Button _actionSheetBtn;
+		private TextField _toastMessage;
+		private Toggle _toastLongDuration;
+		private TextField _shareText;
+		private TextField _shareUrl;
+
+		// ---- Haptics (envelope preview only — haptics never fire in the editor) ----
+		private HapticPreset _previewPreset = HapticPreset.Selection;
+		private VisualElement _envelopeCanvas;
+		private Label _envelopeMaxLabel;
+
+		// ---- Gestures diagnostics ----
+		private GestureController _gestureController;
+		// Auto-spawned in play mode when the scene has no GestureController, so the user needs zero
+		// setup. Destroyed on play exit / panel close.
+		private GameObject _spawnedGestureHost;
+		private GestureController _spawnedGestureController;
+		private bool _touchSimEnabled;
+		private SwipeInput _lastSwipe;
+		private TapInput _lastTap;
+		private bool _hasSwipe;
+		private bool _hasTap;
+		private Label _gestureStatus;
+		private Label _gestureSwipe;
+		private Label _gestureTap;
+
+		// ---- Permissions state controls ----
+		private readonly Dictionary<AppPermission, EnumField> _permStateFields = new Dictionary<AppPermission, EnumField>();
+		private VisualElement _permPendingRow;
+		private Label _permPendingLabel;
+		private AppPermission? _permPending;
+
+		// ---- ATT state control ----
+		private EnumField _attStateField;
+		private VisualElement _attPendingRow;
+
+		public override void OnCreate()
+		{
+			SyncPlatformFromHost();
+			MobileSimulatorRuntimeOverlay.NotifyPluginActive(true);
+		}
+
+		public override void OnDestroy()
+		{
+			MobileSimulatorRuntimeOverlay.NotifyPluginActive(false);
+			EditorPlatformSimulator.Disengage();
+			DetachGestureController();
+			CleanupSpawnedGestures();
+		}
+
+		public override VisualElement OnCreateUI()
+		{
+			var root = new VisualElement { name = "mobile-services-plugin-root" };
+			LoadStyleSheet(root);
+
+			// OnCreateUI can run again (panel re-docked); drop stale control refs from the prior tree.
+			_playModeControls.Clear();
+			_editModeBanners.Clear();
+
+			var scroll = new ScrollView(ScrollViewMode.Vertical);
+			scroll.style.flexGrow = 1;
+
+			scroll.Add(BuildHeader());
+
+			// Everything below the header is gated as a group by the master switch; the header itself
+			// stays interactive so the simulator can always be turned back on.
+			_sectionsContainer = new VisualElement { name = "msp-sections" };
+			_sectionsContainer.Add(BuildPlayModeBanner());
+			_sectionsContainer.Add(BuildNativeUiSection());
+			_sectionsContainer.Add(BuildHapticsSection());
+			_sectionsContainer.Add(BuildNotificationsSection());
+			_sectionsContainer.Add(BuildGesturesSection());
+			_sectionsContainer.Add(BuildPermissionsSection());
+			_sectionsContainer.Add(BuildAttSection());
+			scroll.Add(_sectionsContainer);
+
+			root.Add(scroll);
+
+			// One cheap poll re-syncs the platform skin from the selected device profile AND refreshes
+			// the live-state read-outs. Reading Application.platform (which the Device Simulator spoofs
+			// for iOS / Android picks) is version-agnostic vs DeviceSimulator.deviceChanged.
+			root.schedule.Execute(() =>
+			{
+				SyncPlatformFromHost();
+				RefreshDiagnostics();
+				RefreshPlayModeGating();
+			}).Every(500);
+
+			RebuildEnvelope();
+			// Engage before the first diagnostics refresh so Check() / CurrentStatus already read the
+			// simulated store when the dropdowns sync.
+			ApplyEnabledState(MobileSimulatorState.Enabled);
+			RefreshDiagnostics();
+			RefreshPlayModeGating();
+			ApplyActionSheetButtonState(MobileSimulatorState.Platform);
+			MobileSimulatorState.PlatformChanged += OnPlatformChanged;
+			MobileSimulatorState.EnabledChanged += OnEnabledChanged;
+			root.RegisterCallback<DetachFromPanelEvent>(_ =>
+			{
+				MobileSimulatorState.PlatformChanged -= OnPlatformChanged;
+				MobileSimulatorState.EnabledChanged -= OnEnabledChanged;
+			});
+
+			return root;
+		}
+
+		private static void SyncPlatformFromHost()
+		{
+			switch (Application.platform)
+			{
+				case RuntimePlatform.IPhonePlayer:
+					MobileSimulatorState.Platform = SimulatedPlatform.iOS;
+					break;
+				case RuntimePlatform.Android:
+					MobileSimulatorState.Platform = SimulatedPlatform.Android;
+					break;
+			}
+		}
+
+		private VisualElement BuildHeader()
+		{
+			var header = new VisualElement { name = "msp-header" };
+			header.AddToClassList("msp-header");
+
+			var titleLabel = new Label("Mobile Services");
+			titleLabel.AddToClassList("msp-title");
+			header.Add(titleLabel);
+
+			var note = new Label(HeaderNote);
+			note.AddToClassList("msp-note");
+			header.Add(note);
+
+			_enabledToggle = new Toggle("Editor Simulator") { value = MobileSimulatorState.Enabled };
+			_enabledToggle.AddToClassList("msp-enabled-toggle");
+			_enabledToggle.RegisterValueChangedCallback(evt => MobileSimulatorState.Enabled = evt.newValue);
+			header.Add(_enabledToggle);
+
+			return header;
+		}
+
+		private void OnEnabledChanged(bool enabled) => ApplyEnabledState(enabled);
+
+		private void ApplyEnabledState(bool enabled)
+		{
+			_enabledToggle?.SetValueWithoutNotify(enabled);
+			_sectionsContainer?.SetEnabled(enabled);
+			if (enabled)
+			{
+				EditorPlatformSimulator.Engage();
+			}
+			else
+			{
+				EditorPlatformSimulator.Disengage();
+				MobileSimulatorState.PushDismissAll();
+			}
+		}
+
+		private VisualElement BuildPlayModeBanner()
+		{
+			var banner = new VisualElement { name = "msp-playmode-hint" };
+			banner.AddToClassList("msp-overlay-hint");
+
+			var label = new Label(PlayModeBannerText);
+			label.AddToClassList("msp-overlay-hint-label");
+			banner.Add(label);
+
+			_editModeBanners.Add(banner);
+			return banner;
+		}
+
+		private T GatePlayMode<T>(T control) where T : VisualElement
+		{
+			_playModeControls.Add(control);
+			return control;
+		}
+
+		private void RefreshPlayModeGating()
+		{
+			var isPlaying = Application.isPlaying;
+			foreach (var control in _playModeControls)
+			{
+				control.SetEnabled(isPlaying);
+				control.tooltip = isPlaying ? null : PlayModeTooltip;
+			}
+			foreach (var banner in _editModeBanners)
+			{
+				banner.style.display = isPlaying ? DisplayStyle.None : DisplayStyle.Flex;
+			}
+		}
+
+		// ---- Native UI ----
+
+		private VisualElement BuildNativeUiSection()
+		{
+			var foldout = new Foldout { text = "Native UI", value = true };
+			foldout.AddToClassList("msp-foldout");
+
+			_alertTitle = new TextField("Alert title") { value = DefaultAlertTitle };
+			_alertMessage = new TextField("Alert message") { value = DefaultAlertMessage };
+			foldout.Add(_alertTitle);
+			foldout.Add(_alertMessage);
+
+			foldout.Add(MakeActionButton("Alert (modal)", () => PushAlert(isSheet: false)));
+			_actionSheetBtn = MakeActionButton("Action Sheet", () => PushAlert(isSheet: true));
+			foldout.Add(_actionSheetBtn);
+
+			_toastMessage = new TextField("Toast") { value = DefaultToastMessage };
+			_toastLongDuration = new Toggle("Long duration") { value = false };
+			foldout.Add(_toastMessage);
+			foldout.Add(_toastLongDuration);
+			foldout.Add(MakeActionButton("Show Toast", () =>
+			{
+				MobileSimulatorState.PushToast(new SimulatedToastSpec
+				{
+					Message = _toastMessage.value,
+					IsLongDuration = _toastLongDuration.value,
+				});
+				NativeUiService.ShowToastMessage(_toastMessage.value, _toastLongDuration.value);
+			}));
+
+			_shareText = new TextField("Share text") { value = DefaultShareText };
+			_shareUrl = new TextField("Share URL") { value = DefaultShareUrl };
+			foldout.Add(_shareText);
+			foldout.Add(_shareUrl);
+			foldout.Add(MakeActionButton("Share", () =>
+			{
+				MobileSimulatorState.PushShare(new SimulatedShareSpec { Text = _shareText.value, Url = _shareUrl.value });
+				NativeUiService.Share(_shareText.value, _shareUrl.value);
+			}));
+
+			var dismissBtn = MakeActionButton("Dismiss all UIs", MobileSimulatorState.PushDismissAll);
+			dismissBtn.AddToClassList("msp-button-danger");
+			foldout.Add(dismissBtn);
+
+			return foldout;
+		}
+
+		private void PushAlert(bool isSheet)
+		{
+			var spec = new SimulatedAlertSpec
+			{
+				Title = _alertTitle.value,
+				Message = _alertMessage.value,
+				IsActionSheet = isSheet,
+				Buttons = new List<SimulatedAlertButton>
+				{
+					new SimulatedAlertButton { Text = "Cancel", Style = SimulatedAlertButtonStyle.Cancel },
+					new SimulatedAlertButton { Text = "Delete", Style = SimulatedAlertButtonStyle.Destructive },
+				},
+			};
+			MobileSimulatorState.PushAlert(spec);
+			NativeUiService.ShowAlertPopUp(isSheet, _alertTitle.value, _alertMessage.value,
+				new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel },
+				new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive });
+		}
+
+		private void OnPlatformChanged(SimulatedPlatform platform) => ApplyActionSheetButtonState(platform);
+
+		private void ApplyActionSheetButtonState(SimulatedPlatform platform)
+		{
+			if (_actionSheetBtn == null)
+			{
+				return;
+			}
+			// Android has no native action-sheet idiom — it collapses onto the same Material 3 dialog.
+			var isAndroid = platform == SimulatedPlatform.Android;
+			_actionSheetBtn.SetEnabled(!isAndroid);
+			_actionSheetBtn.tooltip = isAndroid
+				? "Disabled on Android: no native action-sheet idiom. Switch the device to an iPhone to drive the distinct sheet shape."
+				: null;
+		}
+
+		// ---- Haptics (envelope preview only) ----
+
+		private VisualElement BuildHapticsSection()
+		{
+			var foldout = new Foldout { text = "Haptics", value = false };
+			foldout.AddToClassList("msp-foldout");
+
+			var note = new Label(HapticsNote);
+			note.AddToClassList("msp-note");
+			foldout.Add(note);
+
+			var presetGrid = new VisualElement();
+			presetGrid.style.flexDirection = FlexDirection.Row;
+			presetGrid.style.flexWrap = Wrap.Wrap;
+			foreach (HapticPreset preset in Enum.GetValues(typeof(HapticPreset)))
+			{
+				if (preset == HapticPreset.None)
+				{
+					continue;
+				}
+				var captured = preset;
+				var btn = new Button(() =>
+				{
+					_previewPreset = captured;
+					RebuildEnvelope();
+				}) { text = preset.ToString() };
+				btn.style.marginRight = 3;
+				btn.style.marginBottom = 3;
+				presetGrid.Add(btn);
+			}
+			foldout.Add(presetGrid);
+
+			foldout.Add(BuildEnvelopeGraph());
+
+			return foldout;
+		}
+
+		// Step curve, not a smooth line: each HapticEnvelopes segment holds its amplitude for its
+		// duration, which is exactly how VibrationEffect.createWaveform plays it.
+		private VisualElement BuildEnvelopeGraph()
+		{
+			var graph = new VisualElement();
+			graph.style.marginTop = 4;
+			graph.style.marginBottom = 4;
+
+			var title = new Label("Intensity over time");
+			title.AddToClassList("tab-section-label");
+			graph.Add(title);
+
+			var plotRow = new VisualElement();
+			plotRow.style.flexDirection = FlexDirection.Row;
+
+			var yAxis = new VisualElement();
+			yAxis.style.width = EnvelopeYAxisWidth;
+			yAxis.style.height = EnvelopePlotHeight;
+			yAxis.style.justifyContent = Justify.SpaceBetween;
+			yAxis.style.alignItems = Align.FlexEnd;
+			yAxis.style.paddingRight = 4;
+			yAxis.Add(MakeAxisTick("1.0"));
+			yAxis.Add(MakeAxisTick("0.5"));
+			yAxis.Add(MakeAxisTick("0"));
+			plotRow.Add(yAxis);
+
+			_envelopeCanvas = new VisualElement();
+			_envelopeCanvas.AddToClassList("haptic-envelope-canvas");
+			_envelopeCanvas.style.height = EnvelopePlotHeight;
+			_envelopeCanvas.style.flexGrow = 1;
+			var canvas = _envelopeCanvas;
+			_envelopeCanvas.generateVisualContent += mgc => PaintEnvelope(mgc, canvas);
+			plotRow.Add(_envelopeCanvas);
+
+			graph.Add(plotRow);
+
+			var xAxis = new VisualElement();
+			xAxis.style.flexDirection = FlexDirection.Row;
+			xAxis.style.marginLeft = EnvelopeYAxisWidth;
+			xAxis.Add(MakeAxisTick("0 ms"));
+			var spacer = new VisualElement();
+			spacer.style.flexGrow = 1;
+			xAxis.Add(spacer);
+			_envelopeMaxLabel = MakeAxisTick("");
+			xAxis.Add(_envelopeMaxLabel);
+			graph.Add(xAxis);
+
+			var caption = new Label("x: time (ms)   ·   y: intensity (0–1)");
+			caption.AddToClassList("msp-note");
+			graph.Add(caption);
+
+			return graph;
+		}
+
+		private static Label MakeAxisTick(string text)
+		{
+			var lbl = new Label(text);
+			lbl.style.fontSize = 9;
+			lbl.style.color = new Color(0.6f, 0.65f, 0.72f);
+			return lbl;
+		}
+
+		private void RebuildEnvelope()
+		{
+			if (_envelopeCanvas == null)
+			{
+				return;
+			}
+			var (timesSec, _) = HapticEnvelopes.GetFloatEnvelopeFor(_previewPreset);
+			var totalMs = 0f;
+			if (timesSec != null)
+			{
+				for (var i = 0; i < timesSec.Length; i++)
+				{
+					totalMs += timesSec[i] * 1000f;
+				}
+			}
+			if (_envelopeMaxLabel != null)
+			{
+				_envelopeMaxLabel.text = $"{totalMs:F0} ms";
+			}
+			_envelopeCanvas.MarkDirtyRepaint();
+		}
+
+		private void PaintEnvelope(MeshGenerationContext mgc, VisualElement canvas)
+		{
+			var rect = canvas.contentRect;
+			var w = rect.width;
+			var h = rect.height;
+			if (w <= 1f || h <= 1f)
+			{
+				return;
+			}
+
+			var painter = mgc.painter2D;
+
+			// Intensity grid lines (0 / 0.25 / 0.5 / 0.75 / 1.0).
+			painter.lineWidth = 1f;
+			painter.strokeColor = new Color(1f, 1f, 1f, 0.07f);
+			for (var i = 0; i <= 4; i++)
+			{
+				var gy = h * i / 4f;
+				painter.BeginPath();
+				painter.MoveTo(new Vector2(0f, gy));
+				painter.LineTo(new Vector2(w, gy));
+				painter.Stroke();
+			}
+
+			var (timesSec, amps) = HapticEnvelopes.GetFloatEnvelopeFor(_previewPreset);
+			if (timesSec == null || timesSec.Length == 0)
+			{
+				return;
+			}
+			var total = 0f;
+			for (var i = 0; i < timesSec.Length; i++)
+			{
+				total += timesSec[i];
+			}
+			if (total <= 0f)
+			{
+				return;
+			}
+
+			const float pad = 2f;
+			var plotH = h - pad * 2f;
+			float X(float tCumSec) => tCumSec / total * w;
+			float Y(float amp) => pad + (1f - Mathf.Clamp01(amp)) * plotH;
+
+			// Build the step curve: each segment holds its amplitude for its duration, then steps to
+			// the next segment's amplitude.
+			var pts = new List<Vector2>();
+			var cum = 0f;
+			pts.Add(new Vector2(X(0f), Y(amps[0])));
+			for (var i = 0; i < timesSec.Length; i++)
+			{
+				cum += timesSec[i];
+				pts.Add(new Vector2(X(cum), Y(amps[i])));
+				if (i < timesSec.Length - 1)
+				{
+					pts.Add(new Vector2(X(cum), Y(amps[i + 1])));
+				}
+			}
+
+			// Filled area under the curve.
+			painter.fillColor = new Color(0.47f, 0.82f, 1f, 0.18f);
+			painter.BeginPath();
+			painter.MoveTo(new Vector2(X(0f), Y(0f)));
+			foreach (var p in pts)
+			{
+				painter.LineTo(p);
+			}
+			painter.LineTo(new Vector2(X(total), Y(0f)));
+			painter.ClosePath();
+			painter.Fill();
+
+			// The curve stroke.
+			painter.lineWidth = 2f;
+			painter.strokeColor = new Color(0.47f, 0.82f, 1f, 0.95f);
+			painter.BeginPath();
+			painter.MoveTo(pts[0]);
+			for (var i = 1; i < pts.Count; i++)
+			{
+				painter.LineTo(pts[i]);
+			}
+			painter.Stroke();
+		}
+
+		// ---- Notifications ----
+
+		private VisualElement BuildNotificationsSection()
+		{
+			var foldout = new Foldout { text = "Notifications", value = false };
+			foldout.AddToClassList("msp-foldout");
+
+			// Preview-only by design: live scheduling / channels / queueing would run on a throwaway
+			// MobileNotificationService disconnected from the game, duplicating the NotificationsScheduler sample.
+			var note = new Label(NotificationsNote);
+			note.AddToClassList("msp-note");
+			foldout.Add(note);
+
+			foldout.Add(MakeActionButton("Show heads-up banner", () => MobileSimulatorState.PushNotificationBanner(new SimulatedNotificationBannerSpec
+			{
+				ChannelName = "Rewards",
+				Title = DefaultNotificationTitle,
+				Body = DefaultNotificationBody,
+			})));
+
+			var dismissBtn = MakeActionButton("Dismiss Banner", MobileSimulatorState.PushDismissAll);
+			dismissBtn.AddToClassList("msp-button-danger");
+			foldout.Add(dismissBtn);
+
+			return foldout;
+		}
+
+		// ---- Gestures ----
+
+		private VisualElement BuildGesturesSection()
+		{
+			var foldout = new Foldout { text = "Gestures", value = false };
+			foldout.AddToClassList("msp-foldout");
+
+			_gestureStatus = new Label("Enter Play mode to scan for GestureController.");
+			foldout.Add(_gestureStatus);
+
+			var swipeLabel = new Label("Last swipe");
+			swipeLabel.AddToClassList("tab-section-label");
+			foldout.Add(swipeLabel);
+			_gestureSwipe = new Label("(none)");
+			foldout.Add(_gestureSwipe);
+
+			var tapLabel = new Label("Last tap");
+			tapLabel.AddToClassList("tab-section-label");
+			foldout.Add(tapLabel);
+			_gestureTap = new Label("(none)");
+			foldout.Add(_gestureTap);
+
+			var resetBtn = MakeActionButton("Reset", () =>
+			{
+				_hasSwipe = false;
+				_hasTap = false;
+				_gestureSwipe.text = "(none)";
+				_gestureTap.text = "(none)";
+			});
+			resetBtn.AddToClassList("msp-button-danger");
+			foldout.Add(resetBtn);
+
+			return foldout;
+		}
+
+		private void OnSwiped(SwipeInput swipe)
+		{
+			_lastSwipe = swipe;
+			_hasSwipe = true;
+		}
+
+		private void OnTapped(TapInput tap)
+		{
+			_lastTap = tap;
+			_hasTap = true;
+		}
+
+		private void DetachGestureController()
+		{
+			if (_gestureController == null)
+			{
+				return;
+			}
+			_gestureController.Swiped -= OnSwiped;
+			_gestureController.Tapped -= OnTapped;
+			_gestureController = null;
+		}
+
+		private GestureController EnsureSpawnedGestureController()
+		{
+			if (_spawnedGestureController != null)
+			{
+				return _spawnedGestureController;
+			}
+			_spawnedGestureHost = new GameObject("[EditorOnly] MobileServicesGesturePanel")
+			{
+				hideFlags = HideFlags.DontSave,
+			};
+			_spawnedGestureController = _spawnedGestureHost.AddComponent<GestureController>();
+			if (!_touchSimEnabled)
+			{
+				UnityEngine.InputSystem.EnhancedTouch.TouchSimulation.Enable();
+				_touchSimEnabled = true;
+			}
+			return _spawnedGestureController;
+		}
+
+		private void CleanupSpawnedGestures()
+		{
+			if (_spawnedGestureHost != null)
+			{
+				UnityEngine.Object.DestroyImmediate(_spawnedGestureHost);
+			}
+			_spawnedGestureHost = null;
+			_spawnedGestureController = null;
+			if (_touchSimEnabled)
+			{
+				UnityEngine.InputSystem.EnhancedTouch.TouchSimulation.Disable();
+				_touchSimEnabled = false;
+			}
+		}
+
+		// ---- Permissions ----
+
+		private VisualElement BuildPermissionsSection()
+		{
+			var foldout = new Foldout { text = "Permissions", value = false };
+			foldout.AddToClassList("msp-foldout");
+
+			foldout.Add(MakeBanner(PermissionsInfo));
+
+			_permStateFields.Clear();
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				var captured = p;
+				var row = new VisualElement();
+				row.AddToClassList("row");
+				var name = new Label(p.ToString());
+				name.AddToClassList("row-label");
+				row.Add(name);
+
+				var stateField = new EnumField(_permissions.Check(p));
+				stateField.style.minWidth = 130;
+				stateField.RegisterValueChangedCallback(evt =>
+					EditorPlatformSimulator.SetPermissionState(captured, (PermissionStatus)evt.newValue));
+				_permStateFields[p] = stateField;
+				// Writes EditorPrefs, so it is meaningful in edit mode too (survives the Play domain
+				// reload) — no play-mode gate.
+				row.Add(stateField);
+				foldout.Add(row);
+			}
+
+			// Fallback for resolving a pending runtime prompt from the panel, since overlay clicks are
+			// unreliable in the edit-mode Game view. Visible only while a prompt is pending (the poll
+			// toggles it); play-mode gated because prompts only pend at runtime.
+			_permPendingRow = new VisualElement();
+			_permPendingRow.style.flexDirection = FlexDirection.Row;
+			_permPendingRow.style.alignItems = Align.Center;
+			_permPendingRow.style.display = DisplayStyle.None;
+			_permPendingLabel = new Label();
+			_permPendingLabel.style.flexGrow = 1;
+			_permPendingRow.Add(_permPendingLabel);
+			_permPendingRow.Add(GatePlayMode(MakeActionButton("Allow", () =>
+			{
+				if (_permPending.HasValue)
+				{
+					EditorPlatformSimulator.ResolvePendingPermissionPrompt(_permPending.Value, true);
+				}
+			})));
+			var denyBtn = MakeActionButton("Don't Allow", () =>
+			{
+				if (_permPending.HasValue)
+				{
+					EditorPlatformSimulator.ResolvePendingPermissionPrompt(_permPending.Value, false);
+				}
+			});
+			denyBtn.AddToClassList("msp-button-danger");
+			_permPendingRow.Add(GatePlayMode(denyBtn));
+			foldout.Add(_permPendingRow);
+
+			foldout.Add(MakeActionButton("Reset all to NotDetermined (reinstall / Reset Privacy)",
+				EditorPlatformSimulator.ResetAllPermissions));
+
+			return foldout;
+		}
+
+		// ---- ATT ----
+
+		private VisualElement BuildAttSection()
+		{
+			var foldout = new Foldout { text = "App Tracking Transparency", value = false };
+			foldout.AddToClassList("msp-foldout");
+
+			foldout.Add(MakeBanner(AttInfo));
+
+			var stateRow = new VisualElement();
+			stateRow.style.flexDirection = FlexDirection.Row;
+			stateRow.style.alignItems = Align.Center;
+			stateRow.Add(new Label("Status"));
+			_attStateField = new EnumField(_att.CurrentStatus);
+			_attStateField.style.flexGrow = 1;
+			_attStateField.style.marginLeft = 6;
+			_attStateField.RegisterValueChangedCallback(evt => EditorPlatformSimulator.SetAttState((AttStatus)evt.newValue));
+			stateRow.Add(_attStateField);
+			foldout.Add(stateRow);
+
+			// Panel fallback for resolving a pending runtime ATT prompt (overlay clicks are unreliable
+			// in the edit-mode Game view). Visible only while a prompt is pending; play-mode gated.
+			_attPendingRow = new VisualElement();
+			_attPendingRow.style.flexDirection = FlexDirection.Row;
+			_attPendingRow.style.alignItems = Align.Center;
+			_attPendingRow.style.display = DisplayStyle.None;
+			var attPendingLabel = new Label("ATT prompt pending");
+			attPendingLabel.style.flexGrow = 1;
+			_attPendingRow.Add(attPendingLabel);
+			_attPendingRow.Add(GatePlayMode(MakeActionButton("Allow", () => EditorPlatformSimulator.ResolvePendingAttPrompt(true))));
+			var attDeny = MakeActionButton("Ask Not to Track", () => EditorPlatformSimulator.ResolvePendingAttPrompt(false));
+			attDeny.AddToClassList("msp-button-danger");
+			_attPendingRow.Add(GatePlayMode(attDeny));
+			foldout.Add(_attPendingRow);
+
+			foldout.Add(MakeActionButton("Reset to NotDetermined (reinstall / Reset Privacy)",
+				EditorPlatformSimulator.ResetAtt));
+
+			return foldout;
+		}
+
+		// Deep links are intentionally NOT driven from this panel: DeepLinkService.SimulateLinkActivated
+		// is instance-scoped (no static override like Permissions/ATT), so the panel could only fire
+		// into a throwaway instance it owns — never your game's. Use the DeepLinkRouter sample, or
+		// EditorPlatformSimulator.SimulateDeepLink(uri, yourService) from a bootstrap, to drive it live.
+
+		// ---- Shared refresh ----
+
+		private void RefreshDiagnostics()
+		{
+			RefreshGestureDiagnostics();
+			RefreshPermissionDiagnostics();
+			RefreshAttDiagnostics();
+		}
+
+		private void RefreshGestureDiagnostics()
+		{
+			if (_gestureStatus == null)
+			{
+				return;
+			}
+
+			GestureController controller = null;
+			if (Application.isPlaying)
+			{
+				// Prefer a GestureController the user already has in the scene; otherwise spawn our own
+				// so the read-out works with zero setup.
+				controller = UnityEngine.Object.FindAnyObjectByType<GestureController>();
+				if (controller == null)
+				{
+					controller = EnsureSpawnedGestureController();
+				}
+			}
+			else
+			{
+				CleanupSpawnedGestures();
+			}
+
+			if (controller != _gestureController)
+			{
+				DetachGestureController();
+				_gestureController = controller;
+				if (_gestureController != null)
+				{
+					_gestureController.Swiped += OnSwiped;
+					_gestureController.Tapped += OnTapped;
+				}
+			}
+
+			if (!Application.isPlaying)
+			{
+				_gestureStatus.text = "Enter Play mode — a GestureController is auto-attached, no scene setup needed.";
+			}
+			else if (_gestureController == _spawnedGestureController)
+			{
+				_gestureStatus.text = "Auto-attached a GestureController (no scene object needed) — swipe / tap anywhere.";
+			}
+			else
+			{
+				_gestureStatus.text = $"Attached to scene '{_gestureController.gameObject.name}' — swipe / tap anywhere.";
+			}
+
+			if (_hasSwipe)
+			{
+				_gestureSwipe.text = $"dir={_lastSwipe.SwipeDirection}, vel={_lastSwipe.SwipeVelocity:F1}, sameness={_lastSwipe.SwipeSameness:F2}, start={_lastSwipe.StartPosition}, end={_lastSwipe.EndPosition}";
+			}
+			if (_hasTap)
+			{
+				_gestureTap.text = $"press={_lastTap.PressPosition}, release={_lastTap.ReleasePosition}, duration={_lastTap.TapDuration:F3}s";
+			}
+		}
+
+		private void RefreshPermissionDiagnostics()
+		{
+			// SetValueWithoutNotify so syncing the dropdown to Check() doesn't re-fire SetPermissionState.
+			foreach (var kv in _permStateFields)
+			{
+				kv.Value.SetValueWithoutNotify(_permissions.Check(kv.Key));
+			}
+
+			AppPermission? pending = null;
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				if (EditorPlatformSimulator.HasPendingPermissionPrompt(p))
+				{
+					pending = p;
+					break;
+				}
+			}
+			_permPending = pending;
+			if (_permPendingRow != null)
+			{
+				_permPendingRow.style.display = pending.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
+				if (pending.HasValue && _permPendingLabel != null)
+				{
+					_permPendingLabel.text = $"Prompt pending: {pending.Value}";
+				}
+			}
+		}
+
+		private void RefreshAttDiagnostics()
+		{
+			_attStateField?.SetValueWithoutNotify(_att.CurrentStatus);
+			if (_attPendingRow != null)
+			{
+				_attPendingRow.style.display = EditorPlatformSimulator.HasPendingAttPrompt
+					? DisplayStyle.Flex
+					: DisplayStyle.None;
+			}
+		}
+
+		private static Button MakeActionButton(string text, Action onClick)
+		{
+			var btn = new Button(onClick) { text = text };
+			btn.AddToClassList("msp-button");
+			return btn;
+		}
+
+		private static VisualElement MakeBanner(string text)
+		{
+			var banner = new VisualElement();
+			banner.AddToClassList("msp-banner");
+			var label = new Label(text);
+			label.AddToClassList("msp-banner-label");
+			banner.Add(label);
+			return banner;
+		}
+
+		private static void LoadStyleSheet(VisualElement root)
+		{
+			var guids = AssetDatabase.FindAssets("MobileServicesDeviceSimulatorPanel t:StyleSheet");
+			foreach (var guid in guids)
+			{
+				var path = AssetDatabase.GUIDToAssetPath(guid);
+				if (path.EndsWith("MobileServicesDeviceSimulatorPanel.uss"))
+				{
+					var sheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(path);
+					if (sheet != null)
+					{
+						root.styleSheets.Add(sheet);
+					}
+					return;
+				}
+			}
+		}
+	}
+}
diff --git a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs.meta b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs.meta
new file mode 100644
index 0000000..1c47634
--- /dev/null
+++ b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7be771cc023824810a6408d20a65b166
\ No newline at end of file
diff --git a/Editor/Explorer/Overlays.meta b/Editor/Explorer/Overlays.meta
new file mode 100644
index 0000000..3c97bf9
--- /dev/null
+++ b/Editor/Explorer/Overlays.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1ddd3d5975f694046b2ecd4c509e067f
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Explorer/Overlays/MobileSimulator.Android.uss b/Editor/Explorer/Overlays/MobileSimulator.Android.uss
new file mode 100644
index 0000000..c390533
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.Android.uss
@@ -0,0 +1,131 @@
+.simulator-root.platform-android .mock-card {
+    border-radius: 24px;
+    background-color: rgb(240, 240, 246);
+    width: 80%;
+    max-width: 320px;
+}
+
+.simulator-root.platform-android .mock-card-title {
+    -unity-text-align: middle-left;
+    font-size: 14px;
+    margin-bottom: 8px;
+}
+
+.simulator-root.platform-android .mock-card-message {
+    -unity-text-align: middle-left;
+    margin-bottom: 16px;
+}
+
+.simulator-root.platform-android .mock-card-button-row {
+    flex-direction: row;
+    justify-content: flex-end;
+}
+
+.simulator-root.platform-android .mock-card-button {
+    flex-grow: 0;
+    background-color: transparent;
+    color: rgb(40, 100, 180);
+    border-width: 0;
+    border-radius: 8px;
+    padding: 0 12px;
+    height: 32px;
+    -unity-font-style: bold;
+}
+
+.simulator-root.platform-android .mock-card-button-cancel {
+    color: rgb(80, 80, 90);
+}
+
+.simulator-root.platform-android .mock-card-button-destructive {
+    color: rgb(200, 50, 50);
+}
+
+.simulator-root.platform-android .mock-toast-bottom {
+    bottom: 80px;
+}
+
+.simulator-root.platform-android .mock-toast-pill {
+    background-color: rgba(40, 40, 44, 0.92);
+    border-radius: 22px;
+    padding: 10px 18px;
+}
+
+.simulator-root.platform-android .mock-share-card {
+    border-radius: 16px;
+    padding: 12px;
+}
+
+.simulator-root.platform-android .mock-share-tile {
+    flex-direction: row;
+    align-items: center;
+    width: 100%;
+    height: 36px;
+    margin: 2px 0;
+    background-color: rgba(120, 140, 180, 0.12);
+    border-radius: 6px;
+    padding: 0 10px;
+    justify-content: flex-start;
+}
+
+.simulator-root.platform-android .mock-share-tile Label {
+    font-size: 11px;
+}
+
+.simulator-root.platform-android .mock-review-card {
+    border-radius: 16px;
+}
+
+/* Play In-App Review is a bottom sheet: scrim aligns its child to the bottom edge. */
+.simulator-root.platform-android .mock-review-scrim-android {
+    justify-content: flex-end;
+    align-items: stretch;
+}
+
+.simulator-root.platform-android .mock-review-sheet-android {
+    width: 100%;
+    max-width: none;
+    background-color: rgb(252, 252, 255);
+    border-top-left-radius: 24px;
+    border-top-right-radius: 24px;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+    padding: 20px 20px 28px 20px;
+}
+
+.simulator-root.platform-android .mock-review-sheet-android .mock-review-stars {
+    justify-content: flex-start;
+}
+
+.simulator-root.platform-android .mock-notif-top-android {
+    position: absolute;
+    top: 8px;
+    left: 8px;
+    right: 8px;
+}
+
+.simulator-root.platform-android .mock-notif-card {
+    background-color: rgb(252, 252, 255);
+    border-radius: 12px;
+    border-width: 1px;
+    border-color: rgba(0, 0, 0, 0.08);
+}
+
+/* Material heads-up: small circular colored app icon + colored app name. */
+.simulator-root.platform-android .mock-notif-icon {
+    width: 22px;
+    height: 22px;
+    border-radius: 11px;
+    background-color: rgb(33, 150, 83);
+}
+
+.simulator-root.platform-android .mock-notif-icon Label {
+    font-size: 12px;
+}
+
+.simulator-root.platform-android .mock-notif-appname {
+    color: rgb(33, 150, 83);
+}
+
+.simulator-root.platform-android .mock-notif-title {
+    font-size: 13px;
+}
diff --git a/Editor/Explorer/Overlays/MobileSimulator.Android.uss.meta b/Editor/Explorer/Overlays/MobileSimulator.Android.uss.meta
new file mode 100644
index 0000000..9a0967b
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.Android.uss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: 64ed9024af58f405694b4b5a5fa48672
+ScriptedImporter:
+  internalIDToNameTable: []
+  externalObjects: {}
+  serializedVersion: 2
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
+  script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+  disableValidation: 0
+  unsupportedSelectorAction: 0
diff --git a/Editor/Explorer/Overlays/MobileSimulator.Common.uss b/Editor/Explorer/Overlays/MobileSimulator.Common.uss
new file mode 100644
index 0000000..5bcfbde
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.Common.uss
@@ -0,0 +1,292 @@
+.simulator-root {
+    flex-grow: 1;
+    flex-direction: column;
+    background-color: rgb(18, 18, 22);
+}
+
+.simulator-stage {
+    flex-grow: 1;
+    position: relative;
+}
+
+.simulator-watermark {
+    position: absolute;
+    bottom: 8px;
+    right: 8px;
+    padding: 4px 8px;
+    flex-direction: row;
+    align-items: center;
+    background-color: rgba(0, 0, 0, 0.55);
+    border-radius: 4px;
+    border-width: 1px;
+    border-color: rgba(255, 255, 255, 0.2);
+}
+
+.simulator-watermark Label {
+    color: rgb(255, 210, 80);
+    font-size: 10px;
+    -unity-font-style: bold;
+}
+
+.simulator-platform-label {
+    margin-left: 6px;
+    color: rgb(170, 200, 255);
+}
+
+.mock-scrim {
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0, 0, 0, 0.55);
+    justify-content: center;
+    align-items: center;
+}
+
+.mock-card {
+    background-color: rgb(245, 245, 250);
+    color: rgb(20, 20, 22);
+    padding: 16px;
+    width: 80%;
+    max-width: 320px;
+}
+
+.mock-card-title {
+    color: rgb(20, 20, 22);
+    -unity-font-style: bold;
+    font-size: 13px;
+    margin-bottom: 4px;
+}
+
+.mock-card-message {
+    color: rgb(40, 40, 44);
+    font-size: 11px;
+    margin-bottom: 12px;
+    white-space: normal;
+}
+
+.mock-card-message-warning {
+    color: rgb(200, 80, 40);
+    -unity-font-style: italic;
+}
+
+.mock-card-button-row {
+    margin-top: 8px;
+}
+
+.mock-card-button-row-horizontal {
+    flex-direction: row;
+}
+
+.mock-card-button-row-vertical {
+    flex-direction: column;
+}
+
+.mock-card-button {
+    flex-grow: 1;
+    margin: 2px;
+    height: 28px;
+    background-color: rgba(70, 130, 220, 0.95);
+    color: white;
+    border-width: 0;
+    -unity-font-style: bold;
+}
+
+.mock-card-button-cancel {
+    background-color: rgba(120, 120, 130, 0.4);
+    color: rgb(40, 40, 50);
+    -unity-font-style: normal;
+}
+
+.mock-card-button-destructive {
+    background-color: rgba(220, 60, 60, 0.95);
+    color: white;
+}
+
+.mock-toast-top {
+    position: absolute;
+    top: 24px;
+    left: 0;
+    right: 0;
+    align-items: center;
+}
+
+.mock-toast-bottom {
+    position: absolute;
+    bottom: 60px;
+    left: 0;
+    right: 0;
+    align-items: center;
+}
+
+.mock-toast-pill {
+    background-color: rgba(50, 50, 56, 0.95);
+    border-radius: 18px;
+    padding: 8px 16px;
+}
+
+.mock-toast-pill Label {
+    color: rgb(240, 240, 245);
+    font-size: 11px;
+}
+
+.mock-share-card {
+    width: 92%;
+    max-width: 360px;
+}
+
+.mock-share-title {
+    color: rgb(20, 20, 22);
+    -unity-font-style: bold;
+    font-size: 12px;
+    margin-bottom: 6px;
+}
+
+.mock-share-summary {
+    color: rgb(60, 60, 70);
+    font-size: 10px;
+    -unity-font-style: italic;
+    margin-bottom: 10px;
+    white-space: normal;
+}
+
+.mock-share-grid-ios {
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: flex-start;
+}
+
+.mock-share-list-android {
+    flex-direction: column;
+}
+
+.mock-share-tile {
+    width: 70px;
+    height: 56px;
+    margin: 4px;
+    background-color: rgba(100, 120, 160, 0.18);
+    border-radius: 8px;
+    justify-content: center;
+    align-items: center;
+    padding: 4px;
+}
+
+.mock-share-tile Label {
+    color: rgb(40, 40, 60);
+    font-size: 9px;
+}
+
+.mock-review-card {
+}
+
+.mock-review-stars {
+    flex-direction: row;
+    justify-content: center;
+    margin-top: 4px;
+    margin-bottom: 12px;
+}
+
+.mock-review-star {
+    color: rgba(120, 120, 135, 0.55);
+    font-size: 30px;
+    margin: 0 3px;
+}
+
+.mock-review-star-filled {
+    color: rgb(255, 190, 50);
+}
+
+/* Android Play bottom-sheet header: small circular app icon + title row. */
+.mock-review-header-android {
+    flex-direction: row;
+    align-items: center;
+    margin-bottom: 4px;
+}
+
+.mock-review-appicon {
+    width: 32px;
+    height: 32px;
+    border-radius: 16px;
+    background-color: rgb(33, 150, 83);
+    align-items: center;
+    justify-content: center;
+    margin-right: 10px;
+    flex-shrink: 0;
+}
+
+.mock-review-appicon Label {
+    color: white;
+    -unity-font-style: bold;
+    font-size: 16px;
+}
+
+.mock-permission-card {
+}
+
+.mock-notif-card {
+    flex-direction: row;
+    align-items: flex-start;
+    padding: 10px 12px;
+    border-radius: 14px;
+    border-width: 1px;
+    border-color: rgba(0, 0, 0, 0.10);
+    background-color: rgba(248, 248, 250, 0.98);
+}
+
+.mock-notif-icon {
+    width: 38px;
+    height: 38px;
+    border-radius: 9px;
+    background-color: rgb(88, 120, 240);
+    align-items: center;
+    justify-content: center;
+    margin-right: 10px;
+    flex-shrink: 0;
+}
+
+.mock-notif-icon Label {
+    color: white;
+    -unity-font-style: bold;
+    font-size: 18px;
+}
+
+.mock-notif-content {
+    flex-grow: 1;
+    flex-direction: column;
+}
+
+.mock-notif-header {
+    flex-direction: row;
+    align-items: center;
+    margin-bottom: 2px;
+}
+
+.mock-notif-appname {
+    font-size: 10px;
+    color: rgba(20, 20, 25, 0.55);
+    -unity-font-style: bold;
+}
+
+.mock-notif-time {
+    font-size: 10px;
+    color: rgba(20, 20, 25, 0.45);
+}
+
+.mock-notif-title {
+    color: rgb(20, 20, 22);
+    -unity-font-style: bold;
+    font-size: 13px;
+}
+
+.mock-notif-subtitle {
+    color: rgba(20, 20, 22, 0.8);
+    font-size: 12px;
+}
+
+.mock-notif-body {
+    color: rgba(20, 20, 22, 0.72);
+    font-size: 12px;
+    margin-top: 1px;
+    white-space: normal;
+}
diff --git a/Editor/Explorer/Overlays/MobileSimulator.Common.uss.meta b/Editor/Explorer/Overlays/MobileSimulator.Common.uss.meta
new file mode 100644
index 0000000..393af0d
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.Common.uss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: 12e5abad94b3b46a284e8d033abdda94
+ScriptedImporter:
+  internalIDToNameTable: []
+  externalObjects: {}
+  serializedVersion: 2
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
+  script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+  disableValidation: 0
+  unsupportedSelectorAction: 0
diff --git a/Editor/Explorer/Overlays/MobileSimulator.iOS.uss b/Editor/Explorer/Overlays/MobileSimulator.iOS.uss
new file mode 100644
index 0000000..d585958
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.iOS.uss
@@ -0,0 +1,117 @@
+.simulator-root.platform-ios .mock-card {
+    border-radius: 14px;
+    border-width: 0;
+    background-color: rgba(245, 245, 245, 0.98);
+}
+
+.simulator-root.platform-ios .mock-card-alert {
+    width: 70%;
+    max-width: 280px;
+}
+
+.simulator-root.platform-ios .mock-card-sheet {
+    position: absolute;
+    bottom: 12px;
+    left: 12px;
+    right: 12px;
+    width: auto;
+    max-width: none;
+    background-color: transparent;
+    padding: 0;
+}
+
+.simulator-root.platform-ios .mock-card-sheet .mock-card-button {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(0, 122, 255);
+    border-radius: 12px;
+    margin-bottom: 4px;
+    height: 44px;
+    -unity-font-style: normal;
+}
+
+.simulator-root.platform-ios .mock-card-sheet .mock-card-button-cancel {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(0, 122, 255);
+    -unity-font-style: bold;
+    margin-top: 4px;
+}
+
+.simulator-root.platform-ios .mock-card-sheet .mock-card-button-destructive {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(255, 59, 48);
+}
+
+.simulator-root.platform-ios .mock-card-button {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(0, 122, 255);
+    border-radius: 0;
+    border-width: 0;
+    border-top-width: 0;
+    border-color: rgba(0, 0, 0, 0.12);
+    height: 34px;
+}
+
+.simulator-root.platform-ios .mock-card-button-row {
+    border-top-width: 1px;
+    border-color: rgba(0, 0, 0, 0.12);
+    margin-top: 12px;
+}
+
+.simulator-root.platform-ios .mock-card-button-cancel {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(0, 122, 255);
+    -unity-font-style: bold;
+}
+
+.simulator-root.platform-ios .mock-card-button-destructive {
+    background-color: rgba(245, 245, 245, 0.98);
+    color: rgb(255, 59, 48);
+}
+
+.simulator-root.platform-ios .mock-card-title {
+    -unity-text-align: middle-center;
+}
+
+.simulator-root.platform-ios .mock-card-message {
+    -unity-text-align: middle-center;
+}
+
+.simulator-root.platform-ios .mock-toast-top {
+    top: 16px;
+}
+
+.simulator-root.platform-ios .mock-toast-pill {
+    background-color: rgba(50, 50, 50, 0.92);
+}
+
+.simulator-root.platform-ios .mock-notif-top-ios {
+    position: absolute;
+    top: 8px;
+    left: 8px;
+    right: 8px;
+}
+
+.simulator-root.platform-ios .mock-notif-card {
+    background-color: rgba(250, 250, 252, 0.97);
+    border-radius: 16px;
+    border-color: rgba(0, 0, 0, 0.08);
+}
+
+.simulator-root.platform-ios .mock-notif-icon {
+    border-radius: 9px;
+}
+
+/* StoreKit review sheet: a compact centered card titled with the app name. */
+.simulator-root.platform-ios .mock-review-card-ios {
+    width: 74%;
+    max-width: 270px;
+    align-items: center;
+}
+
+.simulator-root.platform-ios .mock-review-card-ios .mock-card-title {
+    font-size: 15px;
+}
+
+.simulator-root.platform-ios .mock-review-card-ios .mock-card-message {
+    -unity-text-align: middle-center;
+}
diff --git a/Editor/Explorer/Overlays/MobileSimulator.iOS.uss.meta b/Editor/Explorer/Overlays/MobileSimulator.iOS.uss.meta
new file mode 100644
index 0000000..d1c1701
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulator.iOS.uss.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: ca122572122064372b1f37414e97c035
+ScriptedImporter:
+  internalIDToNameTable: []
+  externalObjects: {}
+  serializedVersion: 2
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
+  script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+  disableValidation: 0
+  unsupportedSelectorAction: 0
diff --git a/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs
new file mode 100644
index 0000000..cf4cc1f
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs
@@ -0,0 +1,372 @@
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Explorer.Overlays
+{
+	/// <summary>
+	/// Editor-only bootstrap that spawns an in-Game-view <see cref="UIDocument"/> overlay - the
+	/// single simulator canvas - painting the truth-mirror mocks pixel-aligned with the simulated
+	/// device's <c>Screen.*</c> values.
+	/// </summary>
+	/// <remarks>
+	/// <para>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 <see cref="RefreshLifecycle"/> drives spawn /
+	/// teardown.</para>
+	/// <para>The overlay renders inside Unity's runtime UIToolkit panel - <see cref="UIDocument"/>
+	/// is <c>[ExecuteAlways]</c>, 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.</para>
+	/// <para>The <see cref="PanelSettings"/> instance is constructed programmatically (rather than
+	/// shipped as a <c>.asset</c>) to keep the setup editor-only by construction.</para>
+	/// </remarks>
+	[InitializeOnLoad]
+	internal static class MobileSimulatorRuntimeOverlay
+	{
+		private const string HostObjectName = "[EditorOnly] MobileSimulatorOverlay";
+		private const string CommonStyleName = "MobileSimulator.Common";
+		private const string IosStyleName = "MobileSimulator.iOS";
+		private const string AndroidStyleName = "MobileSimulator.Android";
+
+		private static GameObject _hostObject;
+		private static OverlayController _controller;
+		private static bool _pluginActive;
+
+		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();
+		}
+
+		/// <summary>
+		/// Called by <c>MobileServicesDeviceSimulatorPlugin</c> when its panel UI is created or
+		/// destroyed, so the overlay is alive (edit OR play mode) exactly while the Device Simulator
+		/// window is open.
+		/// </summary>
+		internal static void NotifyPluginActive(bool active)
+		{
+			_pluginActive = active;
+			RefreshLifecycle();
+		}
+
+		private static bool ShouldBeAlive => _pluginActive;
+
+		private static void RefreshLifecycle()
+		{
+			if (ShouldBeAlive)
+			{
+				EnsureSpawned();
+			}
+			else
+			{
+				Teardown();
+			}
+		}
+
+		private static void EnsureSpawned()
+		{
+			if (_hostObject != null)
+			{
+				return;
+			}
+
+			// A domain reload resets the statics but can leave the previous host GameObject behind
+			// (it is HideFlags.DontSave, not destroyed by reload in edit mode). Sweep any stale host
+			// so we never paint two overlays after a reload.
+			DestroyStaleHosts();
+
+			var panelSettings = ScriptableObject.CreateInstance<PanelSettings>();
+			panelSettings.name = "MobileSimulator.PanelSettings";
+			// short.MaxValue puts the overlay above any consumer UIDocument that hasn't explicitly
+			// claimed the same priority. Tie-breaks fall back to GameObject name lexicographic order;
+			// "[EditorOnly] ..." sorts near the top thanks to the leading bracket.
+			panelSettings.sortingOrder = short.MaxValue;
+			// Scale the mock USS (authored in logical-point units) UP to the device's native pixel
+			// grid. The Device Simulator reports Screen.width/height in PHYSICAL pixels (e.g. iPhone
+			// 15 Pro = 1179x2556), so ScaleWithScreenSize against a logical-phone reference resolution
+			// yields a scale factor ≈ the device's native scale (~3x) — i.e. 1 USS px ≈ 1 iOS point.
+			// Without this, ConstantPixelSize paints 1 USS px = 1 device px and every mock renders ~1/3
+			// size on a 3x screen. The reference aspect (≈19.5:9) matches modern tall phones, so the
+			// `match` blend barely matters; 0.5 keeps it sane if the device is rotated to landscape.
+			panelSettings.scaleMode = PanelScaleMode.ScaleWithScreenSize;
+			panelSettings.referenceResolution = new Vector2Int(390, 844);
+			panelSettings.screenMatchMode = PanelScreenMatchMode.MatchWidthOrHeight;
+			panelSettings.match = 0.5f;
+			panelSettings.targetTexture = null;
+			panelSettings.clearColor = false;
+			panelSettings.hideFlags = HideFlags.HideAndDontSave;
+
+			_hostObject = new GameObject(HostObjectName)
+			{
+				hideFlags = HideFlags.DontSave,
+				tag = "EditorOnly",
+			};
+			// DontDestroyOnLoad is only meaningful (and only legal without a warning) in play mode;
+			// in edit mode the object simply lives in the active scene as a hidden, unsaved object.
+			if (Application.isPlaying)
+			{
+				Object.DontDestroyOnLoad(_hostObject);
+			}
+
+			var document = _hostObject.AddComponent<UIDocument>();
+			document.panelSettings = panelSettings;
+
+			_controller = new OverlayController(document.rootVisualElement);
+		}
+
+		private static void Teardown()
+		{
+			if (_controller != null)
+			{
+				_controller.Dispose();
+				_controller = null;
+			}
+			if (_hostObject != null)
+			{
+				DestroyHost(_hostObject);
+				_hostObject = null;
+			}
+			DestroyStaleHosts();
+		}
+
+		private static void DestroyStaleHosts()
+		{
+			foreach (var go in Resources.FindObjectsOfTypeAll<GameObject>())
+			{
+				if (go != null && go != _hostObject && go.name == HostObjectName)
+				{
+					DestroyHost(go);
+				}
+			}
+		}
+
+		private static void DestroyHost(GameObject go)
+		{
+			if (Application.isPlaying)
+			{
+				Object.Destroy(go);
+			}
+			else
+			{
+				Object.DestroyImmediate(go);
+			}
+		}
+
+		/// <summary>
+		/// Owns the visual tree + the <see cref="MobileSimulatorState"/> subscriptions for the
+		/// overlay. Same renderer surface the standalone window used to provide; the broker payload
+		/// paints the mocks here.
+		/// </summary>
+		private sealed class OverlayController
+		{
+			private readonly VisualElement _root;
+			private readonly VisualElement _stage;
+			private readonly VisualElement _watermark;
+			private readonly Label _platformLabel;
+			private readonly StyleSheet _commonSheet;
+			private readonly StyleSheet _iosSheet;
+			private readonly StyleSheet _androidSheet;
+
+			internal OverlayController(VisualElement root)
+			{
+				_root = root;
+				_root.style.flexGrow = 1;
+				// Root must not absorb input — only the scrim of an active mock should be modal.
+				_root.pickingMode = PickingMode.Ignore;
+				_root.style.backgroundColor = Color.clear;
+
+				_commonSheet = FindStyleSheet(CommonStyleName);
+				_iosSheet = FindStyleSheet(IosStyleName);
+				_androidSheet = FindStyleSheet(AndroidStyleName);
+				if (_commonSheet != null)
+				{
+					_root.styleSheets.Add(_commonSheet);
+				}
+
+				var rootContainer = new VisualElement { name = "simulator-root" };
+				rootContainer.AddToClassList("simulator-root");
+				rootContainer.style.flexGrow = 1;
+				rootContainer.style.position = Position.Absolute;
+				rootContainer.style.left = 0;
+				rootContainer.style.top = 0;
+				rootContainer.style.right = 0;
+				rootContainer.style.bottom = 0;
+				// Override the dark background — the overlay must let the underlying Game / Simulator
+				// viewport show through. Mock scrims (when an alert or permission dialog is active)
+				// re-introduce their own dimming via the .mock-scrim USS rule.
+				rootContainer.style.backgroundColor = Color.clear;
+				// Empty stage must not steal clicks from the game. The scrim element inside an
+				// active mock has its own (default) picking mode and re-absorbs input on its own.
+				rootContainer.pickingMode = PickingMode.Ignore;
+				_root.Add(rootContainer);
+
+				_stage = new VisualElement { name = "simulator-stage" };
+				_stage.AddToClassList("simulator-stage");
+				rootContainer.Add(_stage);
+
+				_watermark = new VisualElement { name = "simulator-watermark" };
+				_watermark.AddToClassList("simulator-watermark");
+				_watermark.pickingMode = PickingMode.Ignore;
+				_watermark.Add(new Label("[EDITOR SIMULATOR]"));
+				_platformLabel = new Label();
+				_platformLabel.AddToClassList("simulator-platform-label");
+				_watermark.Add(_platformLabel);
+				rootContainer.Add(_watermark);
+
+				ApplyPlatformSheet(MobileSimulatorState.Platform);
+				ApplyEnabledState(MobileSimulatorState.Enabled);
+
+				MobileSimulatorState.EnabledChanged += OnEnabledChanged;
+				MobileSimulatorState.PlatformChanged += OnPlatformChanged;
+				MobileSimulatorState.AlertRequested += OnAlert;
+				MobileSimulatorState.ToastRequested += OnToast;
+				MobileSimulatorState.ShareRequested += OnShare;
+				MobileSimulatorState.ReviewRequested += OnReview;
+				MobileSimulatorState.NotificationBannerRequested += OnNotificationBanner;
+				MobileSimulatorState.PermissionDialogRequested += OnPermissionDialog;
+				MobileSimulatorState.DismissAllRequested += OnDismissAll;
+			}
+
+			internal void Dispose()
+			{
+				MobileSimulatorState.EnabledChanged -= OnEnabledChanged;
+				MobileSimulatorState.PlatformChanged -= OnPlatformChanged;
+				MobileSimulatorState.AlertRequested -= OnAlert;
+				MobileSimulatorState.ToastRequested -= OnToast;
+				MobileSimulatorState.ShareRequested -= OnShare;
+				MobileSimulatorState.ReviewRequested -= OnReview;
+				MobileSimulatorState.NotificationBannerRequested -= OnNotificationBanner;
+				MobileSimulatorState.PermissionDialogRequested -= OnPermissionDialog;
+				MobileSimulatorState.DismissAllRequested -= OnDismissAll;
+			}
+
+			private static StyleSheet FindStyleSheet(string fileBaseName)
+			{
+				var guids = AssetDatabase.FindAssets($"{fileBaseName} t:StyleSheet");
+				foreach (var guid in guids)
+				{
+					var assetPath = AssetDatabase.GUIDToAssetPath(guid);
+					if (assetPath.EndsWith($"{fileBaseName}.uss"))
+					{
+						return AssetDatabase.LoadAssetAtPath<StyleSheet>(assetPath);
+					}
+				}
+				return null;
+			}
+
+			private void OnEnabledChanged(bool enabled) => ApplyEnabledState(enabled);
+
+			private void ApplyEnabledState(bool enabled)
+			{
+				if (_watermark != null)
+				{
+					_watermark.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
+				}
+			}
+
+			private void OnPlatformChanged(SimulatedPlatform platform) => ApplyPlatformSheet(platform);
+
+			private void ApplyPlatformSheet(SimulatedPlatform platform)
+			{
+				if (_iosSheet != null && _root.styleSheets.Contains(_iosSheet))
+				{
+					_root.styleSheets.Remove(_iosSheet);
+				}
+				if (_androidSheet != null && _root.styleSheets.Contains(_androidSheet))
+				{
+					_root.styleSheets.Remove(_androidSheet);
+				}
+
+				var active = platform == SimulatedPlatform.iOS ? _iosSheet : _androidSheet;
+				if (active != null)
+				{
+					_root.styleSheets.Add(active);
+				}
+
+				_root.RemoveFromClassList("platform-ios");
+				_root.RemoveFromClassList("platform-android");
+				_root.AddToClassList(platform == SimulatedPlatform.iOS ? "platform-ios" : "platform-android");
+
+				if (_platformLabel != null)
+				{
+					_platformLabel.text = platform.ToString();
+				}
+			}
+
+			private void OnAlert(SimulatedAlertSpec spec)
+			{
+				ClearStage();
+				_stage.Add(MockBuilders.BuildAlert(MobileSimulatorState.Platform, spec, ClearStage));
+			}
+
+			private void OnToast(SimulatedToastSpec spec)
+			{
+				ClearStage();
+				var toast = MockBuilders.BuildToast(MobileSimulatorState.Platform, spec);
+				_stage.Add(toast);
+				var seconds = spec.IsLongDuration ? 3.5f : 2.0f;
+				_root.schedule.Execute(() =>
+				{
+					if (_stage.Contains(toast))
+					{
+						_stage.Remove(toast);
+					}
+				}).StartingIn((long)(seconds * 1000f));
+			}
+
+			private void OnShare(SimulatedShareSpec spec)
+			{
+				ClearStage();
+				_stage.Add(MockBuilders.BuildShareSheet(MobileSimulatorState.Platform, spec, ClearStage));
+			}
+
+			private void OnReview()
+			{
+				ClearStage();
+				_stage.Add(MockBuilders.BuildReviewPrompt(MobileSimulatorState.Platform, ClearStage));
+			}
+
+			private void OnNotificationBanner(SimulatedNotificationBannerSpec spec)
+			{
+				ClearStage();
+				var banner = MockBuilders.BuildNotificationBanner(MobileSimulatorState.Platform, spec);
+				_stage.Add(banner);
+				_root.schedule.Execute(() =>
+				{
+					if (_stage.Contains(banner))
+					{
+						_stage.Remove(banner);
+					}
+				}).StartingIn(4000);
+			}
+
+			private void OnPermissionDialog(SimulatedPermissionDialogSpec spec)
+			{
+				ClearStage();
+				var dialog = MockBuilders.BuildPermissionDialog(MobileSimulatorState.Platform, spec, result =>
+				{
+					ClearStage();
+					spec.OnResolved?.Invoke(result);
+				});
+				_stage.Add(dialog);
+			}
+
+			private void OnDismissAll()
+			{
+				ClearStage();
+			}
+
+			private void ClearStage()
+			{
+				if (_stage == null)
+				{
+					return;
+				}
+				_stage.Clear();
+			}
+		}
+	}
+}
diff --git a/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs.meta b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs.meta
new file mode 100644
index 0000000..fb01a01
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3fab5c1f05b7243a0bd986b1ce951c29
\ No newline at end of file
diff --git a/Editor/Explorer/Overlays/MobileSimulatorState.cs b/Editor/Explorer/Overlays/MobileSimulatorState.cs
new file mode 100644
index 0000000..68b4dd2
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulatorState.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Collections.Generic;
+using UnityEditor;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Explorer.Overlays
+{
+	/// <summary>
+	/// Platform skin for the simulator overlay canvas. Auto-synced from the selected Device
+	/// Simulator device profile by <c>MobileServicesDeviceSimulatorPlugin</c>; see
+	/// <see cref="MobileSimulatorState.Platform"/>.
+	/// </summary>
+	public enum SimulatedPlatform
+	{
+		iOS,
+		Android,
+	}
+
+	/// <summary>
+	/// Style/role of an alert button rendered by the simulator (mirrors <c>AlertButtonStyle</c>
+	/// without taking a direct dependency on the runtime enum so the overlay file can render
+	/// out-of-the-box without a runtime reference, while staying type-correct at the call site).
+	/// </summary>
+	public enum SimulatedAlertButtonStyle
+	{
+		Default,
+		Destructive,
+		Cancel,
+	}
+
+	/// <summary>
+	/// Plain payload describing one mock dialog button surfaced by the overlay.
+	/// </summary>
+	public sealed class SimulatedAlertButton
+	{
+		public string Text;
+		public SimulatedAlertButtonStyle Style;
+		public Action OnClicked;
+	}
+
+	/// <summary>
+	/// Specification of one mock alert dialog. Whether it renders as a centered modal or a bottom
+	/// action sheet is decided by <see cref="IsActionSheet"/> and the active platform; on Android
+	/// both shapes collapse onto the same Material 3 dialog mock (no native sheet idiom).
+	/// </summary>
+	public sealed class SimulatedAlertSpec
+	{
+		public string Title;
+		public string Message;
+		public bool IsActionSheet;
+		public List<SimulatedAlertButton> Buttons = new List<SimulatedAlertButton>();
+	}
+
+	public sealed class SimulatedToastSpec
+	{
+		public string Message;
+		public bool IsLongDuration;
+	}
+
+	public sealed class SimulatedShareSpec
+	{
+		public string Text;
+		public string Url;
+		public string ImagePath;
+		public string Title;
+	}
+
+	public sealed class SimulatedNotificationBannerSpec
+	{
+		public string ChannelName;
+		public string Title;
+		public string Body;
+		public string SubTitle;
+	}
+
+	public sealed class SimulatedPermissionDialogSpec
+	{
+		public string TypeName;          // e.g. "Camera" / "Photo Library"
+		public string UsageDescription;  // Project-configured NS*UsageDescription text
+		public bool IsAtt;
+		public Action<bool> OnResolved;  // true = allow / false = deny
+	}
+
+	/// <summary>
+	/// Editor-only broker decoupling the Device Simulator plugin and <c>EditorPlatformSimulator</c>
+	/// from the in-Game-view overlay that paints the mock dialogs. See <c>docs/explorer.md</c>.
+	/// </summary>
+	/// <remarks>
+	/// There is now a single renderer surface (the <c>MobileSimulatorRuntimeOverlay</c>), so push
+	/// calls are simple broadcasts with no per-target routing, and the platform skin is a single
+	/// <see cref="Platform"/> value (auto-synced from the Device Simulator device profile).
+	/// </remarks>
+	public static class MobileSimulatorState
+	{
+		private const string PlatformPrefKey = "GameLovers.MobileServicesSimulator.Platform";
+		private const string EnabledPrefKey = "GameLovers.MobileServicesSimulator.Enabled";
+
+		private static SimulatedPlatform _platform = SimulatedPlatform.iOS;
+		private static bool _enabled = true;
+		private static bool _initialized;
+
+		// ---- Enabled (master switch) ----
+
+		/// <summary>
+		/// Fires when <see cref="Enabled"/> changes — the master switch that gates every plugin
+		/// control and shows/hides the in-Game-view simulator banner.
+		/// </summary>
+		public static event Action<bool> EnabledChanged;
+
+		/// <summary>
+		/// Master switch for the editor simulator. When on, the plugin's controls are interactive
+		/// and the in-Game-view "[EDITOR SIMULATOR]" banner is shown; when off, the controls are
+		/// greyed out and the banner is hidden. Persisted to <see cref="EditorPrefs"/>.
+		/// </summary>
+		public static bool Enabled
+		{
+			get
+			{
+				EnsureInitialized();
+				return _enabled;
+			}
+			set
+			{
+				EnsureInitialized();
+				if (_enabled == value)
+				{
+					return;
+				}
+				_enabled = value;
+				EditorPrefs.SetBool(EnabledPrefKey, value);
+				EnabledChanged?.Invoke(value);
+			}
+		}
+
+		// ---- Platform ----
+
+		/// <summary>
+		/// Fires when the <see cref="Platform"/> changes — the platform skin used by the
+		/// in-Game-view overlay (auto-synced from <c>Application.platform</c> by the Device
+		/// Simulator plugin's poll).
+		/// </summary>
+		public static event Action<SimulatedPlatform> PlatformChanged;
+
+		/// <summary>Platform skin for the simulator overlay. Persisted to <see cref="EditorPrefs"/>.</summary>
+		public static SimulatedPlatform Platform
+		{
+			get
+			{
+				EnsureInitialized();
+				return _platform;
+			}
+			set
+			{
+				EnsureInitialized();
+				if (_platform == value)
+				{
+					return;
+				}
+				_platform = value;
+				EditorPrefs.SetInt(PlatformPrefKey, (int)value);
+				PlatformChanged?.Invoke(value);
+			}
+		}
+
+		// ---- Overlay payload streams ----
+
+		public static event Action<SimulatedAlertSpec> AlertRequested;
+		public static event Action<SimulatedToastSpec> ToastRequested;
+		public static event Action<SimulatedShareSpec> ShareRequested;
+		public static event Action ReviewRequested;
+		public static event Action<SimulatedNotificationBannerSpec> NotificationBannerRequested;
+		public static event Action<SimulatedPermissionDialogSpec> PermissionDialogRequested;
+		public static event Action DismissAllRequested;
+
+		// ---- Push entry points ----
+
+		public static void PushAlert(SimulatedAlertSpec spec) => AlertRequested?.Invoke(spec);
+		public static void PushToast(SimulatedToastSpec spec) => ToastRequested?.Invoke(spec);
+		public static void PushShare(SimulatedShareSpec spec) => ShareRequested?.Invoke(spec);
+		public static void PushReview() => ReviewRequested?.Invoke();
+		public static void PushNotificationBanner(SimulatedNotificationBannerSpec spec) =>
+			NotificationBannerRequested?.Invoke(spec);
+		public static void PushPermissionDialog(SimulatedPermissionDialogSpec spec) =>
+			PermissionDialogRequested?.Invoke(spec);
+		public static void PushDismissAll() => DismissAllRequested?.Invoke();
+
+		private static void EnsureInitialized()
+		{
+			if (_initialized)
+			{
+				return;
+			}
+			_initialized = true;
+			_platform = (SimulatedPlatform)EditorPrefs.GetInt(PlatformPrefKey, (int)SimulatedPlatform.iOS);
+			_enabled = EditorPrefs.GetBool(EnabledPrefKey, true);
+		}
+	}
+}
diff --git a/Editor/Explorer/Overlays/MobileSimulatorState.cs.meta b/Editor/Explorer/Overlays/MobileSimulatorState.cs.meta
new file mode 100644
index 0000000..7497709
--- /dev/null
+++ b/Editor/Explorer/Overlays/MobileSimulatorState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75d68129cd9fe49e6b86e8914eafbd88
\ No newline at end of file
diff --git a/Editor/Explorer/Overlays/MockBuilders.cs b/Editor/Explorer/Overlays/MockBuilders.cs
new file mode 100644
index 0000000..ed6b610
--- /dev/null
+++ b/Editor/Explorer/Overlays/MockBuilders.cs
@@ -0,0 +1,426 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Explorer.Overlays
+{
+	/// <summary>
+	/// Builds the <see cref="VisualElement"/> tree for each simulator mock; layout lives in the
+	/// platform USS files in this folder.
+	/// </summary>
+	internal static class MockBuilders
+	{
+		// ---- Alerts / action sheets ----
+
+		internal static VisualElement BuildAlert(SimulatedPlatform platform, SimulatedAlertSpec spec, Action dismissCallback)
+		{
+			var scrim = new VisualElement();
+			scrim.AddToClassList("mock-scrim");
+
+			var card = new VisualElement();
+			card.AddToClassList("mock-card");
+			if (platform == SimulatedPlatform.iOS && spec.IsActionSheet)
+			{
+				card.AddToClassList("mock-card-sheet");
+			}
+			else
+			{
+				card.AddToClassList("mock-card-alert");
+			}
+
+			if (!string.IsNullOrEmpty(spec.Title))
+			{
+				var title = new Label(spec.Title);
+				title.AddToClassList("mock-card-title");
+				card.Add(title);
+			}
+			if (!string.IsNullOrEmpty(spec.Message))
+			{
+				var message = new Label(spec.Message);
+				message.AddToClassList("mock-card-message");
+				card.Add(message);
+			}
+
+			var buttonRow = new VisualElement();
+			buttonRow.AddToClassList("mock-card-button-row");
+			// iOS alerts use a horizontal divider stack; action sheets and Android dialogs flow vertically.
+			if (platform == SimulatedPlatform.iOS && !spec.IsActionSheet && spec.Buttons.Count == 2)
+			{
+				buttonRow.AddToClassList("mock-card-button-row-horizontal");
+			}
+			else
+			{
+				buttonRow.AddToClassList("mock-card-button-row-vertical");
+			}
+
+			foreach (var btnSpec in spec.Buttons)
+			{
+				var btn = new Button(() =>
+				{
+					btnSpec.OnClicked?.Invoke();
+					dismissCallback?.Invoke();
+				}) { text = btnSpec.Text };
+				btn.AddToClassList("mock-card-button");
+				switch (btnSpec.Style)
+				{
+					case SimulatedAlertButtonStyle.Cancel:
+						btn.AddToClassList("mock-card-button-cancel");
+						break;
+					case SimulatedAlertButtonStyle.Destructive:
+						btn.AddToClassList("mock-card-button-destructive");
+						break;
+				}
+				buttonRow.Add(btn);
+			}
+
+			card.Add(buttonRow);
+			scrim.Add(card);
+			return scrim;
+		}
+
+		// ---- Toasts ----
+
+		internal static VisualElement BuildToast(SimulatedPlatform platform, SimulatedToastSpec spec)
+		{
+			var wrapper = new VisualElement();
+			wrapper.AddToClassList(platform == SimulatedPlatform.iOS ? "mock-toast-top" : "mock-toast-bottom");
+			wrapper.pickingMode = PickingMode.Ignore;
+
+			var pill = new VisualElement();
+			pill.AddToClassList("mock-toast-pill");
+			pill.Add(new Label(spec.Message ?? string.Empty));
+			wrapper.Add(pill);
+			return wrapper;
+		}
+
+		// ---- Share sheet ----
+
+		internal static VisualElement BuildShareSheet(SimulatedPlatform platform, SimulatedShareSpec spec, Action dismissCallback)
+		{
+			var scrim = new VisualElement();
+			scrim.AddToClassList("mock-scrim");
+
+			var card = new VisualElement();
+			card.AddToClassList("mock-card");
+			card.AddToClassList("mock-share-card");
+
+			if (!string.IsNullOrEmpty(spec.Title))
+			{
+				var title = new Label(spec.Title);
+				title.AddToClassList("mock-share-title");
+				card.Add(title);
+			}
+
+			var summary = new Label(BuildShareSummary(spec));
+			summary.AddToClassList("mock-share-summary");
+			card.Add(summary);
+
+			var grid = new VisualElement();
+			grid.AddToClassList(platform == SimulatedPlatform.iOS ? "mock-share-grid-ios" : "mock-share-list-android");
+
+			// Stand-in "share targets" (Messages / Mail / Save) — no real wiring, just the shape.
+			var targets = platform == SimulatedPlatform.iOS
+				? new[] { "Messages", "Mail", "Notes", "AirDrop", "Save Image", "Copy Link" }
+				: new[] { "Messages", "Gmail", "Drive", "Bluetooth", "Save image", "Copy link" };
+
+			foreach (var target in targets)
+			{
+				var icon = new VisualElement();
+				icon.AddToClassList("mock-share-tile");
+				icon.Add(new Label(target));
+				grid.Add(icon);
+			}
+
+			card.Add(grid);
+
+			var closeBtn = new Button(() => dismissCallback?.Invoke()) { text = platform == SimulatedPlatform.iOS ? "Cancel" : "Close" };
+			closeBtn.AddToClassList("mock-card-button");
+			closeBtn.AddToClassList("mock-card-button-cancel");
+			card.Add(closeBtn);
+
+			scrim.Add(card);
+			return scrim;
+		}
+
+		private static string BuildShareSummary(SimulatedShareSpec spec)
+		{
+			var parts = new System.Text.StringBuilder();
+			if (!string.IsNullOrEmpty(spec.Text)) parts.Append(spec.Text);
+			if (!string.IsNullOrEmpty(spec.Url))
+			{
+				if (parts.Length > 0) parts.Append(' ');
+				parts.Append(spec.Url);
+			}
+			if (!string.IsNullOrEmpty(spec.ImagePath))
+			{
+				if (parts.Length > 0) parts.Append('\n');
+				parts.Append("[image] ").Append(spec.ImagePath);
+			}
+			if (parts.Length == 0)
+			{
+				return "(empty share payload)";
+			}
+			return parts.ToString();
+		}
+
+		// ---- Review prompt ----
+		// Faithful per-platform shapes: iOS is StoreKit's centered system star sheet (titled with the
+		// app name); Android is the Play In-App Review bottom sheet. Both use Application.productName.
+
+		internal static VisualElement BuildReviewPrompt(SimulatedPlatform platform, Action dismissCallback)
+		{
+			return platform == SimulatedPlatform.iOS
+				? BuildIosReviewPrompt(dismissCallback)
+				: BuildAndroidReviewPrompt(dismissCallback);
+		}
+
+		private static VisualElement BuildIosReviewPrompt(Action dismissCallback)
+		{
+			var scrim = new VisualElement();
+			scrim.AddToClassList("mock-scrim");
+
+			var card = new VisualElement();
+			card.AddToClassList("mock-card");
+			card.AddToClassList("mock-review-card");
+			card.AddToClassList("mock-review-card-ios");
+
+			var title = new Label(AppName());
+			title.AddToClassList("mock-card-title");
+			card.Add(title);
+
+			var subtitle = new Label("Tap a star to rate it on the App Store.");
+			subtitle.AddToClassList("mock-card-message");
+			card.Add(subtitle);
+
+			var buttons = new VisualElement();
+			buttons.AddToClassList("mock-card-button-row");
+			buttons.AddToClassList("mock-card-button-row-horizontal");
+
+			// Submit only appears once the user has picked a rating — matches the system sheet.
+			var submit = new Button(() => dismissCallback?.Invoke()) { text = "Submit" };
+			submit.AddToClassList("mock-card-button");
+			submit.style.display = DisplayStyle.None;
+
+			card.Add(BuildStarRow(_ => submit.style.display = DisplayStyle.Flex));
+
+			var cancel = new Button(() => dismissCallback?.Invoke()) { text = "Not Now" };
+			cancel.AddToClassList("mock-card-button");
+			cancel.AddToClassList("mock-card-button-cancel");
+			buttons.Add(cancel);
+			buttons.Add(submit);
+
+			card.Add(buttons);
+			scrim.Add(card);
+			return scrim;
+		}
+
+		private static VisualElement BuildAndroidReviewPrompt(Action dismissCallback)
+		{
+			// Play review is a bottom sheet with no explicit buttons; rating it (or tapping the scrim)
+			// dismisses. Edit-mode clicks are unreliable, so the panel's Dismiss button is the fallback.
+			var scrim = new VisualElement();
+			scrim.AddToClassList("mock-scrim");
+			scrim.AddToClassList("mock-review-scrim-android");
+			scrim.RegisterCallback<ClickEvent>(evt =>
+			{
+				if (evt.target == scrim)
+				{
+					dismissCallback?.Invoke();
+				}
+			});
+
+			var sheet = new VisualElement();
+			sheet.AddToClassList("mock-card");
+			sheet.AddToClassList("mock-review-card");
+			sheet.AddToClassList("mock-review-sheet-android");
+
+			var header = new VisualElement();
+			header.AddToClassList("mock-review-header-android");
+
+			var icon = new VisualElement();
+			icon.AddToClassList("mock-review-appicon");
+			icon.Add(new Label(AppName().Substring(0, 1).ToUpperInvariant()));
+			header.Add(icon);
+
+			var title = new Label("Rate this app");
+			title.AddToClassList("mock-card-title");
+			header.Add(title);
+			sheet.Add(header);
+
+			var subtitle = new Label("Tell others what you think");
+			subtitle.AddToClassList("mock-card-message");
+			sheet.Add(subtitle);
+
+			sheet.Add(BuildStarRow(_ => dismissCallback?.Invoke()));
+
+			scrim.Add(sheet);
+			return scrim;
+		}
+
+		// Five tappable stars; clicking the i-th fills 0..i and reports the 1-based rating.
+		private static VisualElement BuildStarRow(Action<int> onRated)
+		{
+			var row = new VisualElement();
+			row.AddToClassList("mock-review-stars");
+
+			var stars = new List<Label>();
+			for (var i = 0; i < 5; i++)
+			{
+				var star = new Label("\u2606");
+				star.AddToClassList("mock-review-star");
+				var index = i;
+				star.RegisterCallback<ClickEvent>(_ =>
+				{
+					for (var s = 0; s < stars.Count; s++)
+					{
+						var filled = s <= index;
+						stars[s].text = filled ? "\u2605" : "\u2606";
+						if (filled)
+						{
+							stars[s].AddToClassList("mock-review-star-filled");
+						}
+						else
+						{
+							stars[s].RemoveFromClassList("mock-review-star-filled");
+						}
+					}
+					onRated?.Invoke(index + 1);
+				});
+				stars.Add(star);
+				row.Add(star);
+			}
+			return row;
+		}
+
+		private static string AppName()
+		{
+			var name = Application.productName;
+			return string.IsNullOrEmpty(name) ? "Your App" : name;
+		}
+
+		// ---- Permission / ATT dialog ----
+
+		internal static VisualElement BuildPermissionDialog(SimulatedPlatform platform, SimulatedPermissionDialogSpec spec, Action<bool> dismissCallback)
+		{
+			var scrim = new VisualElement();
+			scrim.AddToClassList("mock-scrim");
+
+			var card = new VisualElement();
+			card.AddToClassList("mock-card");
+			card.AddToClassList("mock-permission-card");
+
+			var title = new Label(BuildPermissionTitle(spec));
+			title.AddToClassList("mock-card-title");
+			card.Add(title);
+
+			var message = new Label(string.IsNullOrEmpty(spec.UsageDescription)
+				? "(no usage description configured — set one in Project Settings > GameLovers > Mobile Services)"
+				: spec.UsageDescription);
+			message.AddToClassList("mock-card-message");
+			if (string.IsNullOrEmpty(spec.UsageDescription))
+			{
+				message.AddToClassList("mock-card-message-warning");
+			}
+			card.Add(message);
+
+			var buttons = new VisualElement();
+			buttons.AddToClassList("mock-card-button-row");
+			buttons.AddToClassList("mock-card-button-row-horizontal");
+
+			var denyText = spec.IsAtt ? "Ask App Not to Track" : "Don't Allow";
+			var allowText = spec.IsAtt ? "Allow" : (platform == SimulatedPlatform.iOS ? "OK" : "Allow");
+
+			if (platform == SimulatedPlatform.iOS)
+			{
+				buttons.Add(MakeBtn(denyText, false, dismissCallback, "mock-card-button-cancel"));
+				buttons.Add(MakeBtn(allowText, true, dismissCallback, null));
+			}
+			else
+			{
+				buttons.Add(MakeBtn(denyText, false, dismissCallback, "mock-card-button-cancel"));
+				buttons.Add(MakeBtn(allowText, true, dismissCallback, null));
+			}
+
+			card.Add(buttons);
+			scrim.Add(card);
+			return scrim;
+		}
+
+		private static Button MakeBtn(string text, bool result, Action<bool> dismissCallback, string extraClass)
+		{
+			var btn = new Button(() => dismissCallback?.Invoke(result)) { text = text };
+			btn.AddToClassList("mock-card-button");
+			if (!string.IsNullOrEmpty(extraClass))
+			{
+				btn.AddToClassList(extraClass);
+			}
+			return btn;
+		}
+
+		private static string BuildPermissionTitle(SimulatedPermissionDialogSpec spec)
+		{
+			if (spec.IsAtt)
+			{
+				return "Allow this app to track your activity across other companies' apps and websites?";
+			}
+			return $"\"YourApp\" Would Like to Access Your {spec.TypeName}";
+		}
+
+		// ---- Heads-up notification banner ----
+
+		internal static VisualElement BuildNotificationBanner(SimulatedPlatform platform, SimulatedNotificationBannerSpec spec)
+		{
+			var wrapper = new VisualElement();
+			wrapper.AddToClassList(platform == SimulatedPlatform.iOS ? "mock-notif-top-ios" : "mock-notif-top-android");
+			wrapper.pickingMode = PickingMode.Ignore;
+
+			// Real heads-up shape: [ app icon ] [ APP NAME ........ now / title / body ].
+			var card = new VisualElement();
+			card.AddToClassList("mock-notif-card");
+
+			var appName = string.IsNullOrEmpty(spec.ChannelName) ? "Your App" : spec.ChannelName;
+
+			var icon = new VisualElement();
+			icon.AddToClassList("mock-notif-icon");
+			icon.Add(new Label(appName.Substring(0, 1).ToUpperInvariant()));
+			card.Add(icon);
+
+			var content = new VisualElement();
+			content.AddToClassList("mock-notif-content");
+
+			var header = new VisualElement();
+			header.AddToClassList("mock-notif-header");
+			var name = new Label(appName.ToUpperInvariant());
+			name.AddToClassList("mock-notif-appname");
+			header.Add(name);
+			var spacer = new VisualElement();
+			spacer.style.flexGrow = 1;
+			header.Add(spacer);
+			var time = new Label("now");
+			time.AddToClassList("mock-notif-time");
+			header.Add(time);
+			content.Add(header);
+
+			var title = new Label(spec.Title ?? string.Empty);
+			title.AddToClassList("mock-notif-title");
+			content.Add(title);
+
+			if (!string.IsNullOrEmpty(spec.SubTitle))
+			{
+				var subtitle = new Label(spec.SubTitle);
+				subtitle.AddToClassList("mock-notif-subtitle");
+				content.Add(subtitle);
+			}
+
+			var body = new Label(spec.Body ?? string.Empty);
+			body.AddToClassList("mock-notif-body");
+			content.Add(body);
+
+			card.Add(content);
+			wrapper.Add(card);
+			return wrapper;
+		}
+	}
+}
diff --git a/Editor/Explorer/Overlays/MockBuilders.cs.meta b/Editor/Explorer/Overlays/MockBuilders.cs.meta
new file mode 100644
index 0000000..2ffe5c0
--- /dev/null
+++ b/Editor/Explorer/Overlays/MockBuilders.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0d98d29bdbecd44a7a4607e3f0970fe6
\ No newline at end of file
diff --git a/Editor/GameLovers.MobileServices.Editor.asmdef b/Editor/GameLovers.MobileServices.Editor.asmdef
new file mode 100644
index 0000000..8ae9d96
--- /dev/null
+++ b/Editor/GameLovers.MobileServices.Editor.asmdef
@@ -0,0 +1,22 @@
+{
+    "name": "GameLovers.MobileServices.Editor",
+    "rootNamespace": "GameLovers.MobileServices.Editor",
+    "references": [
+        "GameLovers.MobileServices",
+        "Unity.InputSystem",
+        "Unity.Notifications",
+        "Unity.Notifications.Android",
+        "Unity.Notifications.iOS"
+    ],
+    "includePlatforms": [
+        "Editor"
+    ],
+    "excludePlatforms": [],
+    "allowUnsafeCode": false,
+    "overrideReferences": false,
+    "precompiledReferences": [],
+    "autoReferenced": true,
+    "defineConstraints": [],
+    "versionDefines": [],
+    "noEngineReferences": false
+}
diff --git a/Runtime/GameLovers.NativeUi.asmdef.meta b/Editor/GameLovers.MobileServices.Editor.asmdef.meta
similarity index 76%
rename from Runtime/GameLovers.NativeUi.asmdef.meta
rename to Editor/GameLovers.MobileServices.Editor.asmdef.meta
index 077a9cb..6cf13b7 100644
--- a/Runtime/GameLovers.NativeUi.asmdef.meta
+++ b/Editor/GameLovers.MobileServices.Editor.asmdef.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 113cff88732a84c2680c9dbbdbefc792
+guid: 16130ccaee8bb4c2889e6ca9c93751e2
 AssemblyDefinitionImporter:
   externalObjects: {}
   userData: 
diff --git a/Editor/Settings.meta b/Editor/Settings.meta
new file mode 100644
index 0000000..2d6f893
--- /dev/null
+++ b/Editor/Settings.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 3d7d96f38101446009c574577d3d50a8
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Settings/MobileServicesConfig.cs b/Editor/Settings/MobileServicesConfig.cs
new file mode 100644
index 0000000..95ce500
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfig.cs
@@ -0,0 +1,471 @@
+using System;
+using System.Collections.Generic;
+using GameLovers.MobileServices.Device;
+using UnityEditor;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Settings
+{
+	/// <summary>
+	/// One localized value for a usage description (e.g. <c>NSCameraUsageDescription</c>). The
+	/// <see cref="LocaleCode"/> is an ISO language code (<c>en</c>, <c>fr</c>, <c>pt-BR</c>, …) and maps
+	/// to an iOS <c>&lt;locale&gt;.lproj/InfoPlist.strings</c> entry at build time so the OS shows the
+	/// right text for the device language.
+	/// </summary>
+	[Serializable]
+	public sealed class LocaleEntry
+	{
+		/// <summary>ISO language code for this value (<c>en</c>, <c>fr</c>, <c>pt-BR</c>, …).</summary>
+		[SerializeField] public string LocaleCode = MobileServicesConfig.DefaultLocaleCode;
+
+		/// <summary>The usage-description text shown to the user for <see cref="LocaleCode"/>.</summary>
+		[TextArea(2, 4)]
+		[SerializeField] public string UsageDescription;
+	}
+
+	/// <summary>One iOS permission and its per-locale usage descriptions.</summary>
+	[Serializable]
+	public sealed class PermissionUsageRow
+	{
+		/// <summary>The permission this row's usage descriptions apply to.</summary>
+		[SerializeField] public AppPermission Permission;
+
+		/// <summary>Per-locale usage-description values for <see cref="Permission"/>.</summary>
+		[SerializeField] public List<LocaleEntry> Entries = new List<LocaleEntry> { new LocaleEntry() };
+	}
+
+	/// <summary>Per-locale values for the App Tracking Transparency (<c>NSUserTrackingUsageDescription</c>) prompt.</summary>
+	[Serializable]
+	public sealed class AttUsageRow
+	{
+		/// <summary>Per-locale ATT usage-description values.</summary>
+		[SerializeField] public List<LocaleEntry> Entries = new List<LocaleEntry> { new LocaleEntry() };
+	}
+
+	/// <summary>iOS capabilities to enable in the post-build Xcode project (Info.plist + entitlements).</summary>
+	[Serializable]
+	public sealed class CapabilityToggles
+	{
+		/// <summary>Adds the Push Notifications capability/entitlement.</summary>
+		[SerializeField] public bool PushNotifications;
+
+		/// <summary>Marks App Tracking Transparency as used (drives the ATT usage-description requirement).</summary>
+		[SerializeField] public bool AppTracking;
+
+		/// <summary>Adds the Associated Domains capability (uses <see cref="AssociatedDomainList"/>).</summary>
+		[SerializeField] public bool AssociatedDomains;
+
+		/// <summary>Associated-domain entries (e.g. <c>applinks:example.com</c>) when <see cref="AssociatedDomains"/> is on.</summary>
+		[SerializeField] public List<string> AssociatedDomainList = new List<string>();
+	}
+
+	/// <summary>Android <c>&lt;uses-permission&gt;</c> / <c>&lt;queries&gt;</c> entries to inject into the manifest template.</summary>
+	[Serializable]
+	public sealed class AndroidManifestToggles
+	{
+		/// <summary>Adds <c>READ_MEDIA_IMAGES</c> (Photo Library on API 33+).</summary>
+		[SerializeField] public bool ReadMediaImages;
+
+		/// <summary>Adds <c>POST_NOTIFICATIONS</c> (API 33+).</summary>
+		[SerializeField] public bool PostNotifications;
+
+		/// <summary>Adds <c>RECORD_AUDIO</c> (Microphone).</summary>
+		[SerializeField] public bool RecordAudio;
+
+		/// <summary>Adds <c>CAMERA</c>.</summary>
+		[SerializeField] public bool Camera;
+
+		/// <summary>Adds <c>ACCESS_FINE_LOCATION</c>.</summary>
+		[SerializeField] public bool AccessFineLocation;
+
+		/// <summary>Adds the share-chooser <c>&lt;queries&gt;</c> block (Android 11+ visibility for share targets).</summary>
+		[SerializeField] public bool IncludeShareQueriesBlock;
+	}
+
+	/// <summary>
+	/// Editor-only configuration asset for the Mobile Services build pipeline (iOS Info.plist /
+	/// entitlements / capabilities, Android manifest + gradle, and editor-tooling toggles). Replaces
+	/// the former <c>ScriptableSingleton</c> in <c>ProjectSettings/</c>: it is a regular
+	/// <see cref="ScriptableObject"/> asset so it edits in the Inspector with the normal serialized-field
+	/// UX (including the per-locale usage-description lists), and a single instance is located ANYWHERE
+	/// in the project via <see cref="Instance"/> / <see cref="GetOrCreateAsset"/>. Open it from
+	/// <c>Tools &gt; GameLovers &gt; Mobile Services &gt; Select Mobile Services Config</c>.
+	/// See <c>docs/build-pipeline.md</c> for the schema and behaviour.
+	/// </summary>
+	/// <remarks>
+	/// The type lives in the Editor assembly, so the asset is editor-only (keep it under an
+	/// <c>Editor/</c> folder; it is never included in player builds). The runtime never reads it.
+	/// </remarks>
+	public sealed class MobileServicesConfig : ScriptableObject
+	{
+		/// <summary>Default/base locale code. Its value is written to the <c>Info.plist</c> root as the fallback.</summary>
+		public const string DefaultLocaleCode = "en";
+
+		/// <summary>Default Maven coordinate for the Play In-App Review library injected on Android builds.</summary>
+		public const string DefaultPlayReviewCoordinate = "com.google.android.play:review:2.0.2";
+
+		[SerializeField] private List<PermissionUsageRow> _permissionDescriptions = new List<PermissionUsageRow>();
+		[SerializeField] private AttUsageRow _attUsageDescription = new AttUsageRow();
+		[SerializeField] private CapabilityToggles _capabilities = new CapabilityToggles();
+		[SerializeField] private AndroidManifestToggles _androidManifest = new AndroidManifestToggles();
+		[SerializeField] private bool _includePlayReviewDependency = true;
+		[SerializeField] private string _playReviewDependencyCoordinate = DefaultPlayReviewCoordinate;
+		[SerializeField] private bool _manageNativeBuildManually;
+
+		private static MobileServicesConfig _cached;
+		private static bool _cachedIsTransient;
+
+		/// <summary>
+		/// The single config for the project, located anywhere via <see cref="AssetDatabase.FindAssets(string)"/>.
+		/// If no asset exists yet, returns a transient in-memory instance carrying the defaults (so build /
+		/// test reads never throw) — create a persisted asset via the
+		/// <c>Tools &gt; GameLovers &gt; Mobile Services &gt; Select Mobile Services Config</c> menu.
+		/// </summary>
+		public static MobileServicesConfig Instance
+		{
+			get
+			{
+				if (_cached != null && !_cachedIsTransient)
+				{
+					return _cached;
+				}
+
+				var found = FindAsset();
+				if (found != null)
+				{
+					_cached = found;
+					_cachedIsTransient = false;
+					return _cached;
+				}
+
+				if (_cached == null)
+				{
+					_cached = CreateInstance<MobileServicesConfig>();
+					_cached.hideFlags = HideFlags.DontSave;
+					_cachedIsTransient = true;
+				}
+				return _cached;
+			}
+		}
+
+		/// <summary>Per-permission usage description rows. Reads as read-only; mutate via the explicit helpers.</summary>
+		public IReadOnlyList<PermissionUsageRow> PermissionDescriptions => _permissionDescriptions;
+
+		/// <summary>ATT (`NSUserTrackingUsageDescription`) per-locale row.</summary>
+		public AttUsageRow AttUsageDescription => _attUsageDescription;
+
+		/// <summary>iOS capability toggles (Info.plist keys + entitlements) applied at build time.</summary>
+		public CapabilityToggles Capabilities => _capabilities;
+
+		/// <summary>Android manifest permission / queries toggles injected into the manifest template at build time.</summary>
+		public AndroidManifestToggles AndroidManifest => _androidManifest;
+
+		/// <summary>
+		/// When <c>true</c> (the default), the Android build postprocessor auto-injects the Play In-App
+		/// Review dependency (<see cref="PlayReviewDependencyCoordinate"/>) into the generated Gradle
+		/// project so <c>NativeUiService.RequestReview()</c> works with zero manual setup. It is a no-op
+		/// if the dependency is already declared by any source (hand-written gradle, EDM4U, another SDK).
+		/// Opt out for non-Play targets (Amazon / Huawei / sideload) or dependency-policy conflicts and
+		/// add it yourself.
+		/// </summary>
+		public bool IncludePlayReviewDependency
+		{
+			get => _includePlayReviewDependency;
+			set
+			{
+				_includePlayReviewDependency = value;
+				Persist();
+			}
+		}
+
+		/// <summary>
+		/// The full <c>group:artifact:version</c> Maven coordinate injected for Play In-App Review.
+		/// Editable so a consumer can repoint to an internal mirror, a pinned/forced version, or a
+		/// variant to resolve a Gradle version conflict. Falls back to
+		/// <see cref="DefaultPlayReviewCoordinate"/> when blank.
+		/// </summary>
+		public string PlayReviewDependencyCoordinate
+		{
+			get => string.IsNullOrWhiteSpace(_playReviewDependencyCoordinate)
+				? DefaultPlayReviewCoordinate
+				: _playReviewDependencyCoordinate;
+			set
+			{
+				_playReviewDependencyCoordinate = value;
+				Persist();
+			}
+		}
+
+		/// <summary>
+		/// When <c>true</c>, the package performs NO native build configuration — it writes nothing to
+		/// the iOS Info.plist / entitlements / capabilities or the Android manifest / gradle, and skips
+		/// the fail-fast iOS usage-description validation. Turn it on when your team manages the native
+		/// build (Xcode / Gradle) yourself or via another build tool. Default OFF (the package
+		/// auto-configures the build). Note: this is BUILD configuration, unrelated to render
+		/// post-processing.
+		/// </summary>
+		public bool ManageNativeBuildManually
+		{
+			get => _manageNativeBuildManually;
+			set
+			{
+				_manageNativeBuildManually = value;
+				Persist();
+			}
+		}
+
+		/// <summary>Marks the asset dirty and saves it (no-op for the transient in-memory fallback instance).</summary>
+		public void Persist()
+		{
+			EditorUtility.SetDirty(this);
+			if (AssetDatabase.Contains(this))
+			{
+				AssetDatabase.SaveAssets();
+			}
+		}
+
+		/// <summary>
+		/// Finds the existing config asset anywhere in the project, or creates one under
+		/// <c>Assets/Editor/</c> if none exists, then caches and returns it. Used by the
+		/// <c>Select Mobile Services Config</c> menu item.
+		/// </summary>
+		public static MobileServicesConfig GetOrCreateAsset()
+		{
+			var found = FindAsset();
+			if (found != null)
+			{
+				_cached = found;
+				_cachedIsTransient = false;
+				return found;
+			}
+
+			const string editorFolder = "Assets/Editor";
+			if (!AssetDatabase.IsValidFolder(editorFolder))
+			{
+				AssetDatabase.CreateFolder("Assets", "Editor");
+			}
+
+			var created = CreateInstance<MobileServicesConfig>();
+			AssetDatabase.CreateAsset(created, $"{editorFolder}/{nameof(MobileServicesConfig)}.asset");
+			AssetDatabase.SaveAssets();
+			AssetDatabase.Refresh();
+			_cached = created;
+			_cachedIsTransient = false;
+			return created;
+		}
+
+		/// <summary>
+		/// Returns the row for <paramref name="permission"/>, creating a fresh one with a default
+		/// locale entry if none exists yet.
+		/// </summary>
+		public PermissionUsageRow GetOrAddRow(AppPermission permission)
+		{
+			foreach (var row in _permissionDescriptions)
+			{
+				if (row.Permission == permission)
+				{
+					return row;
+				}
+			}
+			var newRow = new PermissionUsageRow { Permission = permission };
+			_permissionDescriptions.Add(newRow);
+			Persist();
+			return newRow;
+		}
+
+		/// <summary>Sets the usage description for <paramref name="permission"/> in <paramref name="locale"/>.</summary>
+		public void SetUsageDescription(AppPermission permission, string locale, string text)
+		{
+			var row = GetOrAddRow(permission);
+			SetLocaleEntry(row.Entries, locale, text);
+			Persist();
+		}
+
+		/// <summary>Sets the English usage description for the given permission. Convenience wrapper.</summary>
+		public void SetUsageDescriptionEn(AppPermission permission, string text) =>
+			SetUsageDescription(permission, DefaultLocaleCode, text);
+
+		/// <summary>Sets the ATT usage description in <paramref name="locale"/>.</summary>
+		public void SetAttUsageDescription(string locale, string text)
+		{
+			SetLocaleEntry(_attUsageDescription.Entries, locale, text);
+			Persist();
+		}
+
+		/// <summary>Sets the English ATT usage description. Convenience wrapper.</summary>
+		public void SetAttUsageDescriptionEn(string text) => SetAttUsageDescription(DefaultLocaleCode, text);
+
+		/// <summary>Reads the usage description for <paramref name="permission"/> in <paramref name="locale"/> (null if unset).</summary>
+		public string GetUsageDescription(AppPermission permission, string locale)
+		{
+			foreach (var row in _permissionDescriptions)
+			{
+				if (row.Permission != permission) continue;
+				return GetLocaleEntry(row.Entries, locale);
+			}
+			return null;
+		}
+
+		/// <summary>Reads the English usage description for the given permission (null if unset).</summary>
+		public string GetUsageDescriptionEn(AppPermission permission) =>
+			GetUsageDescription(permission, DefaultLocaleCode);
+
+		/// <summary>Reads the ATT usage description in <paramref name="locale"/> (null if unset).</summary>
+		public string GetAttUsageDescription(string locale) => GetLocaleEntry(_attUsageDescription.Entries, locale);
+
+		/// <summary>Reads the English ATT usage description (null if unset).</summary>
+		public string GetAttUsageDescriptionEn() => GetAttUsageDescription(DefaultLocaleCode);
+
+		/// <summary>
+		/// Every distinct, non-empty locale code configured across all permission rows + ATT, EXCLUDING
+		/// the <see cref="DefaultLocaleCode"/> base (which is written to the Info.plist root). Used by the
+		/// build postprocessor to decide which <c>&lt;locale&gt;.lproj/InfoPlist.strings</c> files to emit.
+		/// </summary>
+		public IReadOnlyList<string> GetNonDefaultLocaleCodes()
+		{
+			var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+			foreach (var row in _permissionDescriptions)
+			{
+				CollectLocales(row.Entries, set);
+			}
+			CollectLocales(_attUsageDescription.Entries, set);
+			set.RemoveWhere(code => string.Equals(code, DefaultLocaleCode, StringComparison.OrdinalIgnoreCase));
+			return new List<string>(set);
+		}
+
+		/// <summary>
+		/// Returns a per-permission "Suggested copy" usage description. These follow Apple's review
+		/// guidelines (concrete, user-visible benefit) so the team isn't left to write the wording
+		/// from scratch.
+		/// </summary>
+		public static string GetSuggestedCopy(AppPermission permission)
+		{
+			switch (permission)
+			{
+				case AppPermission.Camera:
+					return "Allows you to take photos and videos to share inside the app.";
+				case AppPermission.Microphone:
+					return "Allows you to record audio for voice chat and clip sharing.";
+				case AppPermission.LocationWhenInUse:
+					return "Lets us show nearby content while you have the app open.";
+				case AppPermission.LocationAlways:
+					return "Lets us notify you about nearby events even when the app is in the background.";
+				case AppPermission.PhotoLibrary:
+					return "Allows you to attach photos from your library to your in-app content.";
+				case AppPermission.PhotoLibraryAddOnly:
+					return "Lets us save the screenshots and recordings you make in the app to your library.";
+				case AppPermission.Notifications:
+					return "Allows us to send you reward reminders and important game updates.";
+				default:
+					return string.Empty;
+			}
+		}
+
+		/// <summary>Returns a suggested ATT usage description following Apple's review guidelines.</summary>
+		public static string GetSuggestedAttCopy() =>
+			"Your data will be used to provide a better personalised experience and to support our developers.";
+
+		/// <summary>
+		/// Maps <see cref="AppPermission"/> to the iOS Info.plist key the postprocessor must inject.
+		/// </summary>
+		public static string GetIosUsageKey(AppPermission permission)
+		{
+			switch (permission)
+			{
+				case AppPermission.Camera:               return "NSCameraUsageDescription";
+				case AppPermission.Microphone:           return "NSMicrophoneUsageDescription";
+				case AppPermission.LocationWhenInUse:    return "NSLocationWhenInUseUsageDescription";
+				case AppPermission.LocationAlways:       return "NSLocationAlwaysAndWhenInUseUsageDescription";
+				case AppPermission.PhotoLibrary:         return "NSPhotoLibraryUsageDescription";
+				case AppPermission.PhotoLibraryAddOnly:  return "NSPhotoLibraryAddUsageDescription";
+				case AppPermission.Notifications:        return null; // No Info.plist key on iOS.
+				default:                                 return null;
+			}
+		}
+
+		/// <summary>
+		/// Returns the set of permissions that have empty English usage descriptions but ARE referenced
+		/// by the project (per <paramref name="referenced"/>). The build postprocessor consults this
+		/// to decide whether to fail or soft-warn.
+		/// </summary>
+		public IReadOnlyList<AppPermission> GetMissingUsageDescriptions(IEnumerable<AppPermission> referenced)
+		{
+			var missing = new List<AppPermission>();
+			foreach (var p in referenced)
+			{
+				if (GetIosUsageKey(p) == null)
+				{
+					continue;
+				}
+				var text = GetUsageDescriptionEn(p);
+				if (string.IsNullOrWhiteSpace(text))
+				{
+					missing.Add(p);
+				}
+			}
+			return missing;
+		}
+
+		/// <summary>True when ATT capability is enabled but no English usage description is configured.</summary>
+		public bool IsAttUsageDescriptionMissing()
+		{
+			if (!_capabilities.AppTracking)
+			{
+				return false;
+			}
+			return string.IsNullOrWhiteSpace(GetAttUsageDescriptionEn());
+		}
+
+		private static MobileServicesConfig FindAsset()
+		{
+			var guids = AssetDatabase.FindAssets($"t:{nameof(MobileServicesConfig)}");
+			if (guids.Length == 0)
+			{
+				return null;
+			}
+			if (guids.Length > 1)
+			{
+				Debug.LogWarning($"[GameLovers.MobileServices] Multiple {nameof(MobileServicesConfig)} assets found — using " +
+					$"'{AssetDatabase.GUIDToAssetPath(guids[0])}'. Keep a single config asset to avoid ambiguity.");
+			}
+			return AssetDatabase.LoadAssetAtPath<MobileServicesConfig>(AssetDatabase.GUIDToAssetPath(guids[0]));
+		}
+
+		private static void CollectLocales(List<LocaleEntry> entries, HashSet<string> set)
+		{
+			foreach (var entry in entries)
+			{
+				if (entry != null && !string.IsNullOrWhiteSpace(entry.LocaleCode) && !string.IsNullOrWhiteSpace(entry.UsageDescription))
+				{
+					set.Add(entry.LocaleCode.Trim());
+				}
+			}
+		}
+
+		private static string GetLocaleEntry(List<LocaleEntry> entries, string locale)
+		{
+			foreach (var entry in entries)
+			{
+				if (string.Equals(entry.LocaleCode, locale, StringComparison.OrdinalIgnoreCase))
+				{
+					return entry.UsageDescription;
+				}
+			}
+			return null;
+		}
+
+		private static void SetLocaleEntry(List<LocaleEntry> entries, string locale, string text)
+		{
+			foreach (var entry in entries)
+			{
+				if (string.Equals(entry.LocaleCode, locale, StringComparison.OrdinalIgnoreCase))
+				{
+					entry.UsageDescription = text;
+					return;
+				}
+			}
+			entries.Add(new LocaleEntry { LocaleCode = locale, UsageDescription = text });
+		}
+	}
+}
diff --git a/Editor/Settings/MobileServicesConfig.cs.meta b/Editor/Settings/MobileServicesConfig.cs.meta
new file mode 100644
index 0000000..f09adf2
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 06d8a745067df4358a53ff32c9456ef7
\ No newline at end of file
diff --git a/Editor/Settings/MobileServicesConfigEditor.cs b/Editor/Settings/MobileServicesConfigEditor.cs
new file mode 100644
index 0000000..325a639
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfigEditor.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Text;
+using GameLovers.MobileServices.Device;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Settings
+{
+	/// <summary>
+	/// Inspector for <see cref="MobileServicesConfig"/>. The per-locale usage-description lists render
+	/// via the default serialized-property UI (locale code + text, add / remove), augmented with a
+	/// missing-key status box, a project scan, a "suggest copy" filler, and an iOS Privacy Nutrition
+	/// Label draft generator. Open the asset via
+	/// <c>Tools &gt; GameLovers &gt; Mobile Services &gt; Select Mobile Services Config</c>.
+	/// </summary>
+	/// <remarks>
+	/// The enclosing namespace contains <c>.Editor.</c>, so the Unity base class MUST be qualified as
+	/// <see cref="UnityEditor.Editor"/> (a bare <c>Editor</c> would resolve to the
+	/// <c>GameLovers.MobileServices.Editor</c> namespace).
+	/// </remarks>
+	[CustomEditor(typeof(MobileServicesConfig))]
+	internal sealed class MobileServicesConfigEditor : UnityEditor.Editor
+	{
+		private HelpBox _statusBox;
+
+		/// <summary>Builds the config Inspector: per-locale description lists, settings, status, and helper tools.</summary>
+		public override VisualElement CreateInspectorGUI()
+		{
+			var config = (MobileServicesConfig)target;
+
+			var root = new VisualElement();
+
+			var title = new Label("Mobile Services Config");
+			title.style.unityFontStyleAndWeight = FontStyle.Bold;
+			title.style.fontSize = 14;
+			title.style.marginBottom = 4;
+			root.Add(title);
+
+			_statusBox = new HelpBox(string.Empty, HelpBoxMessageType.Info);
+			root.Add(_statusBox);
+
+			root.Add(MakeSection("Usage descriptions (Info.plist) — localized per device language"));
+			var locHint = new Label("Each permission holds one entry per locale (ISO code: en, fr, pt-BR …). " +
+				"The 'en' value is written to Info.plist; other locales are emitted as <locale>.lproj/InfoPlist.strings at build.");
+			locHint.style.whiteSpace = WhiteSpace.Normal;
+			locHint.style.fontSize = 10;
+			locHint.style.unityFontStyleAndWeight = FontStyle.Italic;
+			locHint.style.marginBottom = 4;
+			root.Add(locHint);
+			root.Add(new PropertyField(serializedObject.FindProperty("_permissionDescriptions"), "Permission Descriptions"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_attUsageDescription"), "App Tracking (ATT) Description"));
+
+			root.Add(MakeSection("Capabilities"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_capabilities"), "Capabilities"));
+
+			root.Add(MakeSection("Android manifest"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_androidManifest"), "Android Manifest"));
+
+			root.Add(MakeSection("Android dependencies"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_includePlayReviewDependency"), "Include Play Review Dependency"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_playReviewDependencyCoordinate"), "Play Review Coordinate"));
+
+			root.Add(MakeSection("Build behaviour"));
+			root.Add(new PropertyField(serializedObject.FindProperty("_manageNativeBuildManually"), "Manage Native Build Manually"));
+
+			root.Add(MakeSection("Tools"));
+			root.Add(BuildScanButton(config));
+			root.Add(BuildSuggestCopyButton(config));
+			root.Add(BuildPrivacyNutritionButton(config));
+
+			root.Bind(serializedObject);
+			root.TrackSerializedObjectValue(serializedObject, _ => UpdateStatus(config));
+			UpdateStatus(config);
+			return root;
+		}
+
+		private static Label MakeSection(string text)
+		{
+			var lbl = new Label(text);
+			lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
+			lbl.style.marginTop = 12;
+			lbl.style.marginBottom = 2;
+			return lbl;
+		}
+
+		private void UpdateStatus(MobileServicesConfig config)
+		{
+			if (_statusBox == null)
+			{
+				return;
+			}
+
+			var missing = CountMissingDescriptions(config);
+			if (missing == 0)
+			{
+				_statusBox.messageType = HelpBoxMessageType.Info;
+				_statusBox.text = "All required iOS usage descriptions are configured (English).";
+			}
+			else
+			{
+				_statusBox.messageType = HelpBoxMessageType.Warning;
+				_statusBox.text = $"{missing} usage description(s) missing an English value — fix before an iOS build.";
+			}
+		}
+
+		private static int CountMissingDescriptions(MobileServicesConfig config)
+		{
+			var count = 0;
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				if (MobileServicesConfig.GetIosUsageKey(p) == null) continue;
+				if (string.IsNullOrWhiteSpace(config.GetUsageDescriptionEn(p)))
+				{
+					count++;
+				}
+			}
+			if (config.IsAttUsageDescriptionMissing())
+			{
+				count++;
+			}
+			return count;
+		}
+
+		private Button BuildScanButton(MobileServicesConfig config)
+		{
+			var btn = new Button(() =>
+			{
+				var result = MobileServicesScanner.Scan();
+				var c = config.Capabilities;
+				if (result.UsesNotifications) c.PushNotifications = true;
+				if (result.UsesAtt) c.AppTracking = true;
+				if (result.UsesDeepLinks) c.AssociatedDomains = true;
+
+				var a = config.AndroidManifest;
+				foreach (var p in result.ReferencedPermissions)
+				{
+					switch (p)
+					{
+						case AppPermission.Camera:               a.Camera = true; break;
+						case AppPermission.Microphone:           a.RecordAudio = true; break;
+						case AppPermission.LocationWhenInUse:
+						case AppPermission.LocationAlways:       a.AccessFineLocation = true; break;
+						case AppPermission.PhotoLibrary:
+						case AppPermission.PhotoLibraryAddOnly:  a.ReadMediaImages = true; break;
+						case AppPermission.Notifications:        a.PostNotifications = true; break;
+					}
+				}
+				if (result.UsesNativeUiShare) a.IncludeShareQueriesBlock = true;
+
+				config.Persist();
+				serializedObject.Update();
+				UpdateStatus(config);
+				Debug.Log("[GameLovers.MobileServices] Project scan complete — capability toggles updated.");
+			}) { text = "Scan project for used services" };
+			btn.style.marginTop = 2;
+			return btn;
+		}
+
+		private Button BuildSuggestCopyButton(MobileServicesConfig config)
+		{
+			var btn = new Button(() =>
+			{
+				foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+				{
+					if (MobileServicesConfig.GetIosUsageKey(p) == null) continue;
+					if (!string.IsNullOrWhiteSpace(config.GetUsageDescriptionEn(p))) continue;
+					var copy = MobileServicesConfig.GetSuggestedCopy(p);
+					if (!string.IsNullOrEmpty(copy))
+					{
+						config.SetUsageDescriptionEn(p, copy);
+					}
+				}
+				if (config.Capabilities.AppTracking && string.IsNullOrWhiteSpace(config.GetAttUsageDescriptionEn()))
+				{
+					config.SetAttUsageDescriptionEn(MobileServicesConfig.GetSuggestedAttCopy());
+				}
+				serializedObject.Update();
+				UpdateStatus(config);
+			}) { text = "Fill missing English descriptions with suggested copy" };
+			btn.style.marginTop = 2;
+			return btn;
+		}
+
+		private static VisualElement BuildPrivacyNutritionButton(MobileServicesConfig config)
+		{
+			var output = new TextField { multiline = true };
+			output.style.minHeight = 120;
+			output.style.whiteSpace = WhiteSpace.Normal;
+
+			var btn = new Button(() => output.value = BuildPrivacyNutritionMarkdown(config))
+			{
+				text = "Generate iOS Privacy Nutrition Label draft",
+			};
+			btn.style.marginTop = 2;
+
+			var wrapper = new VisualElement();
+			wrapper.Add(btn);
+			wrapper.Add(output);
+			return wrapper;
+		}
+
+		private static string BuildPrivacyNutritionMarkdown(MobileServicesConfig config)
+		{
+			var sb = new StringBuilder();
+			sb.AppendLine("# Privacy Nutrition Label (draft)");
+			sb.AppendLine();
+			sb.AppendLine("Generated from the Mobile Services Config asset. Review and refine before App Store submission.");
+			sb.AppendLine();
+			sb.AppendLine("## Data Used to Track You");
+			sb.AppendLine(config.Capabilities.AppTracking
+				? "- Identifiers (advertising / device IDs) — App Tracking Transparency is enabled."
+				: "- (none — App Tracking is disabled)");
+			sb.AppendLine();
+			sb.AppendLine("## Data Linked to You");
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				if (MobileServicesConfig.GetIosUsageKey(p) == null) continue;
+				var copy = config.GetUsageDescriptionEn(p);
+				if (string.IsNullOrWhiteSpace(copy)) continue;
+				sb.AppendLine($"- **{p}** — {copy}");
+			}
+			sb.AppendLine();
+			sb.AppendLine("## Data Not Collected");
+			sb.AppendLine("- (review the bound services and document anything that is genuinely not collected)");
+			return sb.ToString();
+		}
+	}
+}
diff --git a/Editor/Settings/MobileServicesConfigEditor.cs.meta b/Editor/Settings/MobileServicesConfigEditor.cs.meta
new file mode 100644
index 0000000..0157c6b
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfigEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 421aa0e4c27f8497686bb63bac594234
\ No newline at end of file
diff --git a/Editor/Settings/MobileServicesConfigMenuItems.cs b/Editor/Settings/MobileServicesConfigMenuItems.cs
new file mode 100644
index 0000000..f2e1118
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfigMenuItems.cs
@@ -0,0 +1,22 @@
+using UnityEditor;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Settings
+{
+	/// <summary>
+	/// Locates (or creates) the single <see cref="MobileServicesConfig"/> asset and selects it, so the
+	/// developer edits it in the Inspector. Mirrors the GameLovers family convention
+	/// (<c>UiConfigsMenuItems</c>, <c>GoogleSheetImporter</c>): a <c>Select …</c> entry under
+	/// <c>Tools &gt; GameLovers &gt; …</c>.
+	/// </summary>
+	public static class MobileServicesConfigMenuItems
+	{
+		[MenuItem("Tools/GameLovers/Mobile Services/Select Mobile Services Config", priority = 100)]
+		private static void SelectMobileServicesConfig()
+		{
+			var config = MobileServicesConfig.GetOrCreateAsset();
+			Selection.activeObject = config;
+			EditorGUIUtility.PingObject(config);
+		}
+	}
+}
diff --git a/Editor/Settings/MobileServicesConfigMenuItems.cs.meta b/Editor/Settings/MobileServicesConfigMenuItems.cs.meta
new file mode 100644
index 0000000..156a4dd
--- /dev/null
+++ b/Editor/Settings/MobileServicesConfigMenuItems.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b2bbf7794db1b4222b7c670e243c4a44
\ No newline at end of file
diff --git a/Editor/Settings/MobileServicesScanner.cs b/Editor/Settings/MobileServicesScanner.cs
new file mode 100644
index 0000000..7e0bf33
--- /dev/null
+++ b/Editor/Settings/MobileServicesScanner.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using GameLovers.MobileServices.Device;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Settings
+{
+	/// <summary>Outcome of <see cref="MobileServicesScanner.Scan"/>.</summary>
+	public sealed class ProjectScanResult
+	{
+		public HashSet<AppPermission> ReferencedPermissions { get; } = new HashSet<AppPermission>();
+		public bool UsesAtt;
+		public bool UsesNotifications;
+		public bool UsesDeepLinks;
+		public bool UsesNativeUiShare;
+	}
+
+	/// <summary>
+	/// Reflection-based scan over user assemblies that pre-fills capability toggles for the
+	/// Settings Provider and the build postprocessor. Pessimistic by design — false positives are
+	/// preferred over false negatives (a build shipping without a required entitlement).
+	/// </summary>
+	internal static class MobileServicesScanner
+	{
+		public static ProjectScanResult Scan()
+		{
+			var result = new ProjectScanResult();
+
+			var notificationsType = typeof(MobileServices.Notifications.MobileNotificationService);
+			var deepLinkType = typeof(DeepLinkService);
+			var permissionsType = typeof(PermissionsService);
+			var permissionsInterface = typeof(IPermissionsService);
+			var attType = typeof(AttService);
+			var attInterface = typeof(IAttService);
+			var nativeUiType = typeof(MobileServices.NativeUi.NativeUiService);
+
+			// Walk every user assembly. Built-in / package assemblies are skipped to keep the scan fast.
+			var assemblies = System.AppDomain.CurrentDomain.GetAssemblies();
+			foreach (var assembly in assemblies)
+			{
+				if (!IsScannableAssembly(assembly)) continue;
+
+				var refs = assembly.GetReferencedAssemblies();
+				var refsRuntime = false;
+				foreach (var name in refs)
+				{
+					if (name.Name == "GameLovers.MobileServices")
+					{
+						refsRuntime = true;
+						break;
+					}
+				}
+				if (!refsRuntime) continue;
+
+				Type[] types;
+				try { types = assembly.GetTypes(); }
+				catch (ReflectionTypeLoadException e) { types = e.Types ?? Array.Empty<Type>(); }
+				catch { continue; }
+
+				foreach (var type in types)
+				{
+					if (type == null) continue;
+					if (TypeReferences(type, notificationsType))     result.UsesNotifications = true;
+					if (TypeReferences(type, deepLinkType))          result.UsesDeepLinks = true;
+					if (TypeReferences(type, attType) ||
+					    TypeReferences(type, attInterface))          result.UsesAtt = true;
+					if (TypeReferences(type, permissionsType) ||
+					    TypeReferences(type, permissionsInterface))
+					{
+						// Pessimistic: when permissions are referenced but we can't infer which ones,
+						// flag every permission as potentially-required so the user is prompted to
+						// fill in usage descriptions explicitly. They can untoggle the ones they
+						// don't actually call.
+						foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+						{
+							result.ReferencedPermissions.Add(p);
+						}
+					}
+					if (TypeReferences(type, nativeUiType))           result.UsesNativeUiShare = true;
+				}
+			}
+
+			return result;
+		}
+
+		private static bool IsScannableAssembly(Assembly assembly)
+		{
+			// Skip Unity-installed / mscorlib / NuGet-style assemblies — their names are well-known.
+			var name = assembly.GetName().Name;
+			if (string.IsNullOrEmpty(name)) return false;
+			if (name.StartsWith("Unity")) return false;
+			if (name.StartsWith("System")) return false;
+			if (name.StartsWith("Microsoft")) return false;
+			if (name.StartsWith("mscorlib")) return false;
+			if (name.StartsWith("netstandard")) return false;
+			if (name.StartsWith("nunit")) return false;
+			if (name.StartsWith("NUnit")) return false;
+			if (name.StartsWith("NSubstitute")) return false;
+			if (name.StartsWith("Mono.")) return false;
+			return true;
+		}
+
+		private static bool TypeReferences(Type type, Type target)
+		{
+			try
+			{
+				foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
+				{
+					if (Match(field.FieldType, target)) return true;
+				}
+				foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
+				{
+					if (Match(prop.PropertyType, target)) return true;
+				}
+				foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
+				{
+					if (Match(method.ReturnType, target)) return true;
+					foreach (var p in method.GetParameters())
+					{
+						if (Match(p.ParameterType, target)) return true;
+					}
+				}
+			}
+			catch
+			{
+				// Reflection on a few framework types throws TypeLoadException intermittently; ignore.
+			}
+			return false;
+		}
+
+		private static bool Match(Type candidate, Type target)
+		{
+			if (candidate == target) return true;
+			if (candidate.IsArray) return Match(candidate.GetElementType(), target);
+			if (candidate.IsGenericType)
+			{
+				foreach (var a in candidate.GetGenericArguments())
+				{
+					if (Match(a, target)) return true;
+				}
+			}
+			return false;
+		}
+	}
+}
diff --git a/Editor/Settings/MobileServicesScanner.cs.meta b/Editor/Settings/MobileServicesScanner.cs.meta
new file mode 100644
index 0000000..82f5bc0
--- /dev/null
+++ b/Editor/Settings/MobileServicesScanner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2f9feee602bd84e6b91fa28288324120
\ No newline at end of file
diff --git a/Editor/Simulation.meta b/Editor/Simulation.meta
new file mode 100644
index 0000000..3e58fee
--- /dev/null
+++ b/Editor/Simulation.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 170a15eb4ebf04a9a812b72be2590692
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Editor/Simulation/EditorPlatformSimulator.cs b/Editor/Simulation/EditorPlatformSimulator.cs
new file mode 100644
index 0000000..202a1d1
--- /dev/null
+++ b/Editor/Simulation/EditorPlatformSimulator.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Editor.Explorer.Overlays;
+using GameLovers.MobileServices.Editor.Settings;
+using GameLovers.MobileServices.NativeUi;
+using UnityEditor;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Editor.Simulation
+{
+	/// <summary>
+	/// Editor-only façade for driving platform state (battery, connectivity, safe area, deep links,
+	/// permissions, ATT) from edit-mode tests and the Device Simulator panel. See
+	/// <c>docs/explorer.md</c> for the comparison with Unity's Device Simulator.
+	/// </summary>
+	public static class EditorPlatformSimulator
+	{
+		/// <summary>
+		/// Static carrier for simulator-driven device snapshot values that the Device Simulator panel
+		/// surfaces directly (the runtime <c>BatteryService</c> reads <c>SystemInfo</c> live and cannot be
+		/// re-routed in the editor without a full poll re-implementation; the panel renders this
+		/// override alongside the real read as the simulator hint).
+		/// </summary>
+		public static class SimulatedDeviceState
+		{
+			public static float? BatteryLevel;
+			public static BatteryStatus? BatteryStatus;
+		}
+
+		private const string PermStorePrefix = "GameLovers.MobileServicesSimulator.Perm.";
+		private const string AttStoreKey = "GameLovers.MobileServicesSimulator.Att";
+
+		private static bool _engaged;
+		private static readonly Dictionary<AppPermission, Action<bool>> _pendingPermissionResolvers =
+			new Dictionary<AppPermission, Action<bool>>();
+		private static Action<bool> _pendingAttResolver;
+
+		/// <summary>True while the ATT prompt is awaiting a decision (e.g. for a panel fallback).</summary>
+		public static bool HasPendingAttPrompt => _pendingAttResolver != null;
+
+		/// <summary>
+		/// Flips the simulator's low-power-mode override AND fans the change through every
+		/// <see cref="BatteryService"/> instance you pass — mirroring what
+		/// <c>NSProcessInfoPowerStateDidChangeNotification</c> would do on a real iOS device.
+		/// </summary>
+		public static void SetIosLowPowerMode(bool enabled, params BatteryService[] services)
+		{
+			BatteryService.EditorLowPowerModeOverride = enabled;
+			if (services != null)
+			{
+				foreach (var s in services)
+				{
+					s?.SimulateLowPowerModeChanged();
+				}
+			}
+		}
+
+		/// <summary>
+		/// Pushes a safe-area override that <see cref="SafeAreaService"/> will report on its next
+		/// <c>LateUpdate</c> diff. Each service you pass is forced to diff immediately so the
+		/// Explorer surfaces the change without waiting for the host's poll.
+		/// </summary>
+		public static void SetSafeArea(Rect safeArea, params SafeAreaService[] services)
+		{
+			SafeAreaService.EditorSafeAreaOverride = safeArea;
+			if (services != null)
+			{
+				foreach (var s in services)
+				{
+					s?.SimulateSafeAreaChanged();
+				}
+			}
+		}
+
+		/// <summary>Clears the safe-area override and restores the live <c>Screen.safeArea</c> read.</summary>
+		public static void ClearSafeAreaOverride(params SafeAreaService[] services)
+		{
+			SafeAreaService.EditorSafeAreaOverride = null;
+			if (services != null)
+			{
+				foreach (var s in services)
+				{
+					s?.SimulateSafeAreaChanged();
+				}
+			}
+		}
+
+		/// <summary>
+		/// Overrides <c>SystemInfo.batteryLevel</c> exposure on the next poll. The package does
+		/// not currently fan a battery-level event from the simulator (the runtime relies on
+		/// <c>SystemInfo</c> directly); the Device Simulator panel shows the value via the live snapshot.
+		/// </summary>
+		public static void SetBatteryLevel(float level01)
+		{
+			level01 = Mathf.Clamp01(level01);
+			SimulatedDeviceState.BatteryLevel = level01;
+		}
+
+		/// <summary>Same caveat as <see cref="SetBatteryLevel"/>.</summary>
+		public static void SetBatteryStatus(BatteryStatus status)
+		{
+			SimulatedDeviceState.BatteryStatus = status;
+		}
+
+		/// <summary>
+		/// Mimics the OS handing the app a runtime deep link (post-launch). Supersedes any pending
+		/// cold-start link and fans the URI through every <c>OnLinkActivated</c> subscriber.
+		/// </summary>
+		public static void SimulateDeepLink(Uri uri, params DeepLinkService[] services)
+		{
+			if (uri == null || services == null)
+			{
+				return;
+			}
+			foreach (var s in services)
+			{
+				s?.SimulateLinkActivated(uri);
+			}
+		}
+
+		/// <summary>
+		/// Installs the editor overrides that make <see cref="PermissionsService"/> and
+		/// <see cref="AttService"/> behave like the real OS: <see cref="IPermissionsService.Check"/> /
+		/// <see cref="IAttService.CurrentStatus"/> read the persisted simulated decision, and the first
+		/// <see cref="IPermissionsService.RequestAsync"/> / <see cref="IAttService.RequestAuthorizationAsync"/>
+		/// for a <c>NotDetermined</c> entry shows a prompt in the in-Game-view overlay. Called by the
+		/// Device Simulator panel while it is open and the master switch is on.
+		/// </summary>
+		public static void Engage()
+		{
+			_engaged = true;
+			PermissionsService.EditorCheckOverride = ReadPermissionStore;
+			PermissionsService.EditorRequestAsyncOverride = RequestPermissionAsync;
+			AttService.EditorCurrentStatusOverride = ReadAttStore();
+			AttService.EditorRequestAsyncOverride = RequestAttAsync;
+			// 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();
+		}
+
+		/// <summary>
+		/// Removes every editor override so a non-engaged editor session (and headless test runs)
+		/// keeps the default <see cref="PermissionStatus.Granted"/> / <see cref="AttStatus.Authorized"/>
+		/// short-circuit. Clears any prompt awaiting a decision.
+		/// </summary>
+		public static void Disengage()
+		{
+			_engaged = false;
+			PermissionsService.EditorCheckOverride = null;
+			PermissionsService.EditorRequestOverride = null;
+			PermissionsService.EditorRequestAsyncOverride = null;
+			AttService.EditorCurrentStatusOverride = null;
+			AttService.EditorRequestResultOverride = null;
+			AttService.EditorRequestAsyncOverride = null;
+			NativeUiService.EditorRequestReviewOverride = null;
+			_pendingPermissionResolvers.Clear();
+			_pendingAttResolver = null;
+		}
+
+		/// <summary>
+		/// Sets the persisted simulated decision for <paramref name="permission"/> — the editor
+		/// equivalent of the user changing it in the OS Settings app. Setting
+		/// <see cref="PermissionStatus.NotDetermined"/> re-arms the first-time prompt.
+		/// </summary>
+		public static void SetPermissionState(AppPermission permission, PermissionStatus status) =>
+			WritePermissionStore(permission, status);
+
+		/// <summary>Reads the persisted simulated decision for <paramref name="permission"/>.</summary>
+		public static PermissionStatus GetPermissionState(AppPermission permission) =>
+			ReadPermissionStore(permission);
+
+		/// <summary>
+		/// Resets every permission to <see cref="PermissionStatus.NotDetermined"/> — the editor
+		/// equivalent of a reinstall / "Reset Location &amp; Privacy".
+		/// </summary>
+		public static void ResetAllPermissions()
+		{
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				WritePermissionStore(p, PermissionStatus.NotDetermined);
+			}
+		}
+
+		/// <summary>True while a permission prompt is awaiting a decision (e.g. for a panel fallback).</summary>
+		public static bool HasPendingPermissionPrompt(AppPermission permission) =>
+			_pendingPermissionResolvers.ContainsKey(permission);
+
+		/// <summary>
+		/// Resolves a pending permission prompt from outside the overlay (the Device Simulator panel
+		/// fallback), since overlay clicks are unreliable in the edit-mode Game view.
+		/// </summary>
+		public static void ResolvePendingPermissionPrompt(AppPermission permission, bool allow)
+		{
+			if (!_pendingPermissionResolvers.TryGetValue(permission, out var resolve))
+			{
+				return;
+			}
+			MobileSimulatorState.PushDismissAll();
+			resolve(allow);
+		}
+
+		/// <summary>
+		/// Sets the persisted simulated ATT decision. Setting <see cref="AttStatus.NotDetermined"/>
+		/// re-arms the first-time prompt.
+		/// </summary>
+		public static void SetAttState(AttStatus status) => WriteAttStore(status);
+
+		/// <summary>Reads the persisted simulated ATT decision.</summary>
+		public static AttStatus GetAttState() => ReadAttStore();
+
+		/// <summary>Resets ATT to <see cref="AttStatus.NotDetermined"/> (reinstall / Reset Privacy).</summary>
+		public static void ResetAtt() => WriteAttStore(AttStatus.NotDetermined);
+
+		/// <summary>Resolves a pending ATT prompt from outside the overlay (panel fallback).</summary>
+		public static void ResolvePendingAttPrompt(bool allow)
+		{
+			var resolve = _pendingAttResolver;
+			if (resolve == null)
+			{
+				return;
+			}
+			MobileSimulatorState.PushDismissAll();
+			resolve(allow);
+		}
+
+		/// <summary>Closes any active simulator-overlay dialog without firing a button callback.</summary>
+		public static void DismissAllOverlays() => MobileSimulatorState.PushDismissAll();
+
+		private static PermissionStatus ReadPermissionStore(AppPermission permission) =>
+			(PermissionStatus)EditorPrefs.GetInt(PermStorePrefix + permission, (int)PermissionStatus.NotDetermined);
+
+		private static void WritePermissionStore(AppPermission permission, PermissionStatus status) =>
+			EditorPrefs.SetInt(PermStorePrefix + permission, (int)status);
+
+		private static AttStatus ReadAttStore() =>
+			(AttStatus)EditorPrefs.GetInt(AttStoreKey, (int)AttStatus.NotDetermined);
+
+		private static void WriteAttStore(AttStatus status)
+		{
+			EditorPrefs.SetInt(AttStoreKey, (int)status);
+			// CurrentStatus reads a value (not a func), so keep it synced to the store live.
+			if (_engaged)
+			{
+				AttService.EditorCurrentStatusOverride = status;
+			}
+		}
+
+		private static Task<PermissionStatus> RequestPermissionAsync(AppPermission permission)
+		{
+			var current = ReadPermissionStore(permission);
+			if (current != PermissionStatus.NotDetermined)
+			{
+				// Already decided — the OS returns the cached decision without re-prompting.
+				return Task.FromResult(current);
+			}
+
+			var tcs = new TaskCompletionSource<PermissionStatus>();
+			void Resolve(bool allow)
+			{
+				_pendingPermissionResolvers.Remove(permission);
+				var result = allow ? PermissionStatus.Granted : PermissionStatus.Denied;
+				WritePermissionStore(permission, result);
+				tcs.TrySetResult(result);
+			}
+			_pendingPermissionResolvers[permission] = Resolve;
+
+			MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec
+			{
+				TypeName = Humanize(permission),
+				UsageDescription = MobileServicesConfig.Instance.GetUsageDescriptionEn(permission),
+				IsAtt = false,
+				OnResolved = Resolve,
+			});
+			return tcs.Task;
+		}
+
+		private static Task<AttStatus> RequestAttAsync()
+		{
+			// ATT applies on iOS only — Android / other skins behave like the real AttService.
+			if (MobileSimulatorState.Platform != SimulatedPlatform.iOS)
+			{
+				return Task.FromResult(AttStatus.Authorized);
+			}
+
+			var current = ReadAttStore();
+			if (current != AttStatus.NotDetermined)
+			{
+				return Task.FromResult(current);
+			}
+
+			var tcs = new TaskCompletionSource<AttStatus>();
+			void Resolve(bool allow)
+			{
+				_pendingAttResolver = null;
+				var result = allow ? AttStatus.Authorized : AttStatus.Denied;
+				WriteAttStore(result);
+				tcs.TrySetResult(result);
+			}
+			_pendingAttResolver = Resolve;
+
+			MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec
+			{
+				IsAtt = true,
+				UsageDescription = MobileServicesConfig.Instance.GetAttUsageDescriptionEn(),
+				OnResolved = Resolve,
+			});
+			return tcs.Task;
+		}
+
+		private static string Humanize(AppPermission permission)
+		{
+			switch (permission)
+			{
+				case AppPermission.LocationWhenInUse:
+				case AppPermission.LocationAlways:
+					return "Location";
+				case AppPermission.PhotoLibrary:
+				case AppPermission.PhotoLibraryAddOnly:
+					return "Photos";
+				default:
+					return permission.ToString();
+			}
+		}
+	}
+}
diff --git a/Editor/Simulation/EditorPlatformSimulator.cs.meta b/Editor/Simulation/EditorPlatformSimulator.cs.meta
new file mode 100644
index 0000000..1d11379
--- /dev/null
+++ b/Editor/Simulation/EditorPlatformSimulator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1566c7280d49d42f095271b435488481
\ No newline at end of file
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..6b83528
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,7 @@
+Copyright 2025 Miguel Tomás
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/LICENSE.md.meta b/LICENSE.md.meta
new file mode 100644
index 0000000..87e2c58
--- /dev/null
+++ b/LICENSE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 96dbb61d72e02415d94268a13a538e50
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Plugins.meta b/Plugins.meta
similarity index 77%
rename from Runtime/Plugins.meta
rename to Plugins.meta
index 7517683..5df7541 100644
--- a/Runtime/Plugins.meta
+++ b/Plugins.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: cb80f701082174eb6851afe7b470be9f
+guid: 6393a4195aa9944d5b3feb6e8084e3ca
 folderAsset: yes
 DefaultImporter:
   externalObjects: {}
diff --git a/Runtime/Plugins/iOS.meta b/Plugins/iOS.meta
similarity index 77%
rename from Runtime/Plugins/iOS.meta
rename to Plugins/iOS.meta
index 4a1a031..670f493 100644
--- a/Runtime/Plugins/iOS.meta
+++ b/Plugins/iOS.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 51d04891c7a3041f290bd67d1e602e2b
+guid: eb7c761f639284b489d42ce2b3050a69
 folderAsset: yes
 DefaultImporter:
   externalObjects: {}
diff --git a/Plugins/iOS/Att.m b/Plugins/iOS/Att.m
new file mode 100644
index 0000000..65a44b5
--- /dev/null
+++ b/Plugins/iOS/Att.m
@@ -0,0 +1,53 @@
+#import <Foundation/Foundation.h>
+#import <AppTrackingTransparency/AppTrackingTransparency.h>
+
+extern void UnitySendMessage(const char *gameObject, const char *method, const char *message);
+
+// Mirrors GameLovers.MobileServices.Device.AttStatus.
+typedef NS_ENUM(NSInteger, GLAttStatus)
+{
+    GLAttStatusNotDetermined = 0,
+    GLAttStatusRestricted    = 1,
+    GLAttStatusDenied        = 2,
+    GLAttStatusAuthorized    = 3
+};
+
+static GLAttStatus GLMapAttStatus(ATTrackingManagerAuthorizationStatus s)
+{
+    switch (s)
+    {
+        case ATTrackingManagerAuthorizationStatusNotDetermined: return GLAttStatusNotDetermined;
+        case ATTrackingManagerAuthorizationStatusRestricted:    return GLAttStatusRestricted;
+        case ATTrackingManagerAuthorizationStatusDenied:        return GLAttStatusDenied;
+        case ATTrackingManagerAuthorizationStatusAuthorized:    return GLAttStatusAuthorized;
+        default:                                                return GLAttStatusNotDetermined;
+    }
+}
+
+int _GameLoversAttCurrentStatus(void)
+{
+    if (@available(iOS 14, *))
+    {
+        return GLMapAttStatus([ATTrackingManager trackingAuthorizationStatus]);
+    }
+    return GLAttStatusAuthorized;
+}
+
+void _GameLoversAttRequestAuthorization(int requestId, const char *callbackGameObject, const char *callbackMethod)
+{
+    NSString *goName     = [NSString stringWithUTF8String:callbackGameObject];
+    NSString *methodName = [NSString stringWithUTF8String:callbackMethod];
+
+    if (@available(iOS 14, *))
+    {
+        [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) {
+            NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)GLMapAttStatus(status)];
+            UnitySendMessage([goName UTF8String], [methodName UTF8String], [payload UTF8String]);
+        }];
+    }
+    else
+    {
+        NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)GLAttStatusAuthorized];
+        UnitySendMessage([goName UTF8String], [methodName UTF8String], [payload UTF8String]);
+    }
+}
diff --git a/Plugins/iOS/Att.m.meta b/Plugins/iOS/Att.m.meta
new file mode 100644
index 0000000..71bdf82
--- /dev/null
+++ b/Plugins/iOS/Att.m.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1f3989467231e4524a5b497a15e96dd5
\ No newline at end of file
diff --git a/Plugins/iOS/Battery.m b/Plugins/iOS/Battery.m
new file mode 100644
index 0000000..4030170
--- /dev/null
+++ b/Plugins/iOS/Battery.m
@@ -0,0 +1,39 @@
+#import <Foundation/Foundation.h>
+
+extern void UnitySendMessage(const char *gameObject, const char *method, const char *message);
+
+static id gLowPowerObserver = nil;
+
+bool _GameLoversBatteryIsLowPowerModeEnabled(void)
+{
+    return [[NSProcessInfo processInfo] isLowPowerModeEnabled];
+}
+
+void _GameLoversBatteryStartObservingLowPowerMode(void)
+{
+    if (gLowPowerObserver != nil)
+    {
+        return;
+    }
+
+    gLowPowerObserver = [[NSNotificationCenter defaultCenter]
+        addObserverForName:NSProcessInfoPowerStateDidChangeNotification
+                    object:nil
+                     queue:[NSOperationQueue mainQueue]
+                usingBlock:^(NSNotification * _Nonnull note) {
+            // The shared DeviceServicesHost MonoBehaviour is named "DeviceServicesHost" and
+            // exposes OnIosLowPowerModeChanged(string) as a public method invokable by SendMessage.
+            UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", "");
+        }];
+}
+
+void _GameLoversBatteryStopObservingLowPowerMode(void)
+{
+    if (gLowPowerObserver == nil)
+    {
+        return;
+    }
+
+    [[NSNotificationCenter defaultCenter] removeObserver:gLowPowerObserver];
+    gLowPowerObserver = nil;
+}
diff --git a/Plugins/iOS/Battery.m.meta b/Plugins/iOS/Battery.m.meta
new file mode 100644
index 0000000..6a5a889
--- /dev/null
+++ b/Plugins/iOS/Battery.m.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 091645eba84794445bb9a6c86a77967f
\ No newline at end of file
diff --git a/Plugins/iOS/Haptics.m b/Plugins/iOS/Haptics.m
new file mode 100644
index 0000000..17bbe98
--- /dev/null
+++ b/Plugins/iOS/Haptics.m
@@ -0,0 +1,195 @@
+#import <UIKit/UIKit.h>
+
+// Preset ids must mirror GameLovers.MobileServices.Haptics.HapticPreset enum.
+typedef NS_ENUM(NSInteger, GLHapticPresetId)
+{
+    GLHapticPresetIdNone         = 0,
+    GLHapticPresetIdSelection    = 1,
+    GLHapticPresetIdSuccess      = 2,
+    GLHapticPresetIdWarning      = 3,
+    GLHapticPresetIdError        = 4,
+    GLHapticPresetIdImpactLight  = 5,
+    GLHapticPresetIdImpactMedium = 6,
+    GLHapticPresetIdImpactHeavy  = 7,
+    GLHapticPresetIdImpactRigid  = 8,
+    GLHapticPresetIdImpactSoft   = 9
+};
+
+// Reusable generators. Lazily created on first use; reset to nil on Stop so the next call
+// gets a freshly-prepared generator (Apple docs: prepare/play within ~100ms for best feel).
+static UISelectionFeedbackGenerator    *gSelectionGen    = nil;
+static UINotificationFeedbackGenerator *gNotificationGen = nil;
+static UIImpactFeedbackGenerator       *gImpactGen       = nil;
+static NSTimer                         *gLoopTimer       = nil;
+static GLHapticPresetId                 gLoopPresetId    = GLHapticPresetIdNone;
+
+static void GLHapticsPlaySelection(void)
+{
+    if (gSelectionGen == nil)
+    {
+        gSelectionGen = [[UISelectionFeedbackGenerator alloc] init];
+    }
+    [gSelectionGen prepare];
+    [gSelectionGen selectionChanged];
+}
+
+static void GLHapticsPlayNotification(UINotificationFeedbackType type)
+{
+    if (gNotificationGen == nil)
+    {
+        gNotificationGen = [[UINotificationFeedbackGenerator alloc] init];
+    }
+    [gNotificationGen prepare];
+    [gNotificationGen notificationOccurred:type];
+}
+
+static void GLHapticsPlayImpact(UIImpactFeedbackStyle style)
+{
+    // UIImpactFeedbackGenerator is bound to a single style at init time, so we re-create
+    // when the requested style changes. Keeping it cached when reused (e.g. looping a single style).
+    static UIImpactFeedbackStyle sLastStyle = (UIImpactFeedbackStyle)-1;
+    if (gImpactGen == nil || sLastStyle != style)
+    {
+        gImpactGen  = [[UIImpactFeedbackGenerator alloc] initWithStyle:style];
+        sLastStyle  = style;
+    }
+    [gImpactGen prepare];
+    [gImpactGen impactOccurred];
+}
+
+static void GLHapticsPlayPresetById(GLHapticPresetId presetId)
+{
+    switch (presetId)
+    {
+        case GLHapticPresetIdNone:
+            return;
+        case GLHapticPresetIdSelection:
+            GLHapticsPlaySelection();
+            return;
+        case GLHapticPresetIdSuccess:
+            GLHapticsPlayNotification(UINotificationFeedbackTypeSuccess);
+            return;
+        case GLHapticPresetIdWarning:
+            GLHapticsPlayNotification(UINotificationFeedbackTypeWarning);
+            return;
+        case GLHapticPresetIdError:
+            GLHapticsPlayNotification(UINotificationFeedbackTypeError);
+            return;
+        case GLHapticPresetIdImpactLight:
+            GLHapticsPlayImpact(UIImpactFeedbackStyleLight);
+            return;
+        case GLHapticPresetIdImpactMedium:
+            GLHapticsPlayImpact(UIImpactFeedbackStyleMedium);
+            return;
+        case GLHapticPresetIdImpactHeavy:
+            GLHapticsPlayImpact(UIImpactFeedbackStyleHeavy);
+            return;
+        case GLHapticPresetIdImpactRigid:
+            if (@available(iOS 13.0, *))
+            {
+                GLHapticsPlayImpact(UIImpactFeedbackStyleRigid);
+            }
+            else
+            {
+                GLHapticsPlayImpact(UIImpactFeedbackStyleHeavy);
+            }
+            return;
+        case GLHapticPresetIdImpactSoft:
+            if (@available(iOS 13.0, *))
+            {
+                GLHapticsPlayImpact(UIImpactFeedbackStyleSoft);
+            }
+            else
+            {
+                GLHapticsPlayImpact(UIImpactFeedbackStyleLight);
+            }
+            return;
+    }
+}
+
+static void GLHapticsCancelLoopTimer(void)
+{
+    if (gLoopTimer != nil)
+    {
+        [gLoopTimer invalidate];
+        gLoopTimer = nil;
+    }
+    gLoopPresetId = GLHapticPresetIdNone;
+}
+
+static void GLHapticsLoopTick(NSTimer *timer)
+{
+    (void)timer;
+    GLHapticsPlayPresetById(gLoopPresetId);
+}
+
+void _GameLoversHapticsPreset(int presetId)
+{
+    dispatch_async(dispatch_get_main_queue(), ^{
+        GLHapticsPlayPresetById((GLHapticPresetId)presetId);
+    });
+}
+
+void _GameLoversHapticsLoopStart(int presetId)
+{
+    // Loop interval is intentionally short (~120ms) so the device feels continuously vibrating;
+    // each tick re-fires the system feedback generator which emits a sub-100ms haptic.
+    dispatch_async(dispatch_get_main_queue(), ^{
+        GLHapticsCancelLoopTimer();
+        gLoopPresetId = (GLHapticPresetId)presetId;
+        GLHapticsPlayPresetById(gLoopPresetId);
+        gLoopTimer = [NSTimer scheduledTimerWithTimeInterval:0.12
+                                                     repeats:YES
+                                                       block:^(NSTimer *t) { GLHapticsLoopTick(t); }];
+    });
+}
+
+void _GameLoversHapticsCustom(float intensity, float durationMs)
+{
+    // UIKit feedback generators don't expose intensity directly. We approximate by mapping the
+    // [0,1] intensity to one of three impact styles, fire it, and (if duration > ~150ms) loop a
+    // similar pattern until the C# auto-stop coroutine fires _GameLoversHapticsStop().
+    dispatch_async(dispatch_get_main_queue(), ^{
+        UIImpactFeedbackStyle style;
+        if (intensity < 0.34f)
+        {
+            style = UIImpactFeedbackStyleLight;
+        }
+        else if (intensity < 0.67f)
+        {
+            style = UIImpactFeedbackStyleMedium;
+        }
+        else
+        {
+            style = UIImpactFeedbackStyleHeavy;
+        }
+        GLHapticsPlayImpact(style);
+
+        // Custom haptics with finite duration > ~150ms loop the chosen style on a timer until Stop.
+        if (durationMs > 150.0f)
+        {
+            GLHapticsCancelLoopTimer();
+            // Re-use the loop timer infrastructure: pretend it's the matching impact preset.
+            switch (style)
+            {
+                case UIImpactFeedbackStyleLight:  gLoopPresetId = GLHapticPresetIdImpactLight;  break;
+                case UIImpactFeedbackStyleMedium: gLoopPresetId = GLHapticPresetIdImpactMedium; break;
+                case UIImpactFeedbackStyleHeavy:  gLoopPresetId = GLHapticPresetIdImpactHeavy;  break;
+                default:                          gLoopPresetId = GLHapticPresetIdImpactMedium; break;
+            }
+            gLoopTimer = [NSTimer scheduledTimerWithTimeInterval:0.12
+                                                         repeats:YES
+                                                           block:^(NSTimer *t) { GLHapticsLoopTick(t); }];
+        }
+    });
+}
+
+void _GameLoversHapticsStop(void)
+{
+    dispatch_async(dispatch_get_main_queue(), ^{
+        GLHapticsCancelLoopTimer();
+        gSelectionGen    = nil;
+        gNotificationGen = nil;
+        gImpactGen       = nil;
+    });
+}
diff --git a/Plugins/iOS/Haptics.m.meta b/Plugins/iOS/Haptics.m.meta
new file mode 100644
index 0000000..dda52a5
--- /dev/null
+++ b/Plugins/iOS/Haptics.m.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ebdd587bcd34d45e89dfe5a178b30501
\ No newline at end of file
diff --git a/Plugins/iOS/NativeUi.m b/Plugins/iOS/NativeUi.m
new file mode 100644
index 0000000..9d93721
--- /dev/null
+++ b/Plugins/iOS/NativeUi.m
@@ -0,0 +1,128 @@
+#import <UIKit/UIKit.h>
+#import <StoreKit/StoreKit.h>
+
+extern UIViewController *UnityGetGLViewController();
+
+NSString *ToNSString(char* string) {
+    return [NSString stringWithUTF8String:string];
+}
+
+typedef void (*AlertButtonCallback)(const char * str);
+
+void _GameLoversAlertMessage (bool isSheet, char* title, char* message, char* buttonsText[], int buttonsStyle[], int buttonsLength, AlertButtonCallback buttonCallback)
+{
+    UIAlertControllerStyle style = isSheet ? UIAlertControllerStyleActionSheet : UIAlertControllerStyleAlert;
+    UIAlertController *alert = [UIAlertController alertControllerWithTitle:ToNSString(title) message:ToNSString(message) preferredStyle:style];
+
+    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)
+        {
+            buttonCallback((char*)[buttonText UTF8String]);
+        }];
+        [alert addAction:button];
+    }
+
+    dispatch_async(dispatch_get_main_queue(), ^{
+        [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil];
+    });
+}
+
+void _GameLoversToastMessage (char* message, BOOL isLongDuration)
+{
+    float duration = isLongDuration ? 3.5 : 2;
+    UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:ToNSString(message) preferredStyle:UIAlertControllerStyleAlert];
+
+    [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil];
+
+    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, duration * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
+        [alert dismissViewControllerAnimated:YES completion:nil];
+    });
+}
+
+void _GameLoversRequestReview(void)
+{
+    dispatch_async(dispatch_get_main_queue(), ^{
+        if (@available(iOS 14.0, *))
+        {
+            UIViewController *vc = UnityGetGLViewController();
+            UIWindowScene *scene = (UIWindowScene *)vc.view.window.windowScene;
+            if (scene != nil)
+            {
+                [SKStoreReviewController requestReviewInScene:scene];
+                return;
+            }
+        }
+        // iOS 10.3 - 13.x fallback (and the unlikely case where windowScene is nil on iOS 14+).
+        if ([SKStoreReviewController respondsToSelector:@selector(requestReview)])
+        {
+            [SKStoreReviewController requestReview];
+        }
+    });
+}
+
+void _GameLoversShare(const char *text, const char *url, const char *imagePath)
+{
+    NSString *nsText      = (text != NULL && *text != 0)           ? [NSString stringWithUTF8String:text]      : nil;
+    NSString *nsUrl       = (url != NULL && *url != 0)             ? [NSString stringWithUTF8String:url]       : nil;
+    NSString *nsImagePath = (imagePath != NULL && *imagePath != 0) ? [NSString stringWithUTF8String:imagePath] : nil;
+
+    dispatch_async(dispatch_get_main_queue(), ^{
+        NSMutableArray *items = [NSMutableArray array];
+
+        if (nsText != nil)
+        {
+            [items addObject:nsText];
+        }
+
+        if (nsUrl != nil)
+        {
+            NSURL *parsedUrl = [NSURL URLWithString:nsUrl];
+            if (parsedUrl != nil)
+            {
+                [items addObject:parsedUrl];
+            }
+            else
+            {
+                [items addObject:nsUrl];
+            }
+        }
+
+        if (nsImagePath != nil)
+        {
+            UIImage *image = [UIImage imageWithContentsOfFile:nsImagePath];
+            if (image != nil)
+            {
+                [items addObject:image];
+            }
+            else
+            {
+                NSLog(@"[GameLovers.MobileServices] Share: failed to load image at path %@", nsImagePath);
+            }
+        }
+
+        if ([items count] == 0)
+        {
+            NSLog(@"[GameLovers.MobileServices] Share called with no content; skipping.");
+            return;
+        }
+
+        UIActivityViewController *activityVc = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
+        UIViewController *root = UnityGetGLViewController();
+
+        // iPad popover anchor: centre of the root view, zero-sized rect (avoids assertion).
+        if (activityVc.popoverPresentationController != nil)
+        {
+            activityVc.popoverPresentationController.sourceView = root.view;
+            activityVc.popoverPresentationController.sourceRect = CGRectMake(CGRectGetMidX(root.view.bounds),
+                                                                              CGRectGetMidY(root.view.bounds),
+                                                                              0,
+                                                                              0);
+            activityVc.popoverPresentationController.permittedArrowDirections = 0;
+        }
+
+        [root presentViewController:activityVc animated:YES completion:nil];
+    });
+}
diff --git a/Runtime/Plugins/iOS/NativeUi.m.meta b/Plugins/iOS/NativeUi.m.meta
similarity index 94%
rename from Runtime/Plugins/iOS/NativeUi.m.meta
rename to Plugins/iOS/NativeUi.m.meta
index 1dc462b..8633b4b 100644
--- a/Runtime/Plugins/iOS/NativeUi.m.meta
+++ b/Plugins/iOS/NativeUi.m.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: c0d5a9a64318149cc8389caa86d26e6d
+guid: f75285768bb8048ce83a504d68d9ea72
 PluginImporter:
   externalObjects: {}
   serializedVersion: 2
diff --git a/Plugins/iOS/Permissions.m b/Plugins/iOS/Permissions.m
new file mode 100644
index 0000000..b60ebb0
--- /dev/null
+++ b/Plugins/iOS/Permissions.m
@@ -0,0 +1,267 @@
+#import <Foundation/Foundation.h>
+#import <AVFoundation/AVFoundation.h>
+#import <CoreLocation/CoreLocation.h>
+#import <Photos/Photos.h>
+#import <UserNotifications/UserNotifications.h>
+
+extern void UnitySendMessage(const char *gameObject, const char *method, const char *message);
+
+// Mirrors GameLovers.MobileServices.Device.AppPermission enum.
+typedef NS_ENUM(NSInteger, GLAppPermission)
+{
+    GLAppPermissionCamera              = 0,
+    GLAppPermissionMicrophone          = 1,
+    GLAppPermissionLocationWhenInUse   = 2,
+    GLAppPermissionLocationAlways      = 3,
+    GLAppPermissionPhotoLibrary        = 4,
+    GLAppPermissionPhotoLibraryAddOnly = 5,
+    GLAppPermissionNotifications       = 6
+};
+
+// Mirrors GameLovers.MobileServices.Device.PermissionStatus enum.
+typedef NS_ENUM(NSInteger, GLPermissionStatus)
+{
+    GLPermissionStatusNotDetermined = 0,
+    GLPermissionStatusDenied        = 1,
+    GLPermissionStatusGranted       = 2,
+    GLPermissionStatusRestricted    = 3
+};
+
+// Held to keep CLLocationManager alive long enough for the delegate callback.
+@interface GLLocationDelegate : NSObject <CLLocationManagerDelegate>
+@property (nonatomic, assign) int requestId;
+@property (nonatomic, copy)   NSString *callbackGameObject;
+@property (nonatomic, copy)   NSString *callbackMethod;
+@property (nonatomic, strong) CLLocationManager *manager;
+@end
+
+@implementation GLLocationDelegate
+
+- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager
+{
+    GLPermissionStatus status = GLPermissionStatusNotDetermined;
+    switch (manager.authorizationStatus)
+    {
+        case kCLAuthorizationStatusAuthorizedAlways:
+        case kCLAuthorizationStatusAuthorizedWhenInUse:
+            status = GLPermissionStatusGranted;
+            break;
+        case kCLAuthorizationStatusDenied:
+            status = GLPermissionStatusDenied;
+            break;
+        case kCLAuthorizationStatusRestricted:
+            status = GLPermissionStatusRestricted;
+            break;
+        case kCLAuthorizationStatusNotDetermined:
+        default:
+            return; // Wait for the user to actually decide before responding.
+    }
+
+    NSString *payload = [NSString stringWithFormat:@"%d:%ld", _requestId, (long)status];
+    UnitySendMessage([_callbackGameObject UTF8String], [_callbackMethod UTF8String], [payload UTF8String]);
+
+    _manager.delegate = nil;
+    _manager = nil;
+}
+
+@end
+
+static NSMutableArray<GLLocationDelegate *> *gLocationDelegates = nil;
+
+static GLPermissionStatus MapAVAuthorizationStatus(AVAuthorizationStatus s)
+{
+    switch (s)
+    {
+        case AVAuthorizationStatusAuthorized:    return GLPermissionStatusGranted;
+        case AVAuthorizationStatusDenied:        return GLPermissionStatusDenied;
+        case AVAuthorizationStatusRestricted:    return GLPermissionStatusRestricted;
+        case AVAuthorizationStatusNotDetermined: return GLPermissionStatusNotDetermined;
+        default:                                 return GLPermissionStatusNotDetermined;
+    }
+}
+
+static GLPermissionStatus MapPHAuthorizationStatus(PHAuthorizationStatus s)
+{
+    switch (s)
+    {
+        case PHAuthorizationStatusAuthorized: return GLPermissionStatusGranted;
+        case PHAuthorizationStatusLimited:    return GLPermissionStatusGranted;
+        case PHAuthorizationStatusDenied:     return GLPermissionStatusDenied;
+        case PHAuthorizationStatusRestricted: return GLPermissionStatusRestricted;
+        case PHAuthorizationStatusNotDetermined:
+        default:                              return GLPermissionStatusNotDetermined;
+    }
+}
+
+static GLPermissionStatus MapUNAuthorizationStatus(UNAuthorizationStatus s)
+{
+    switch (s)
+    {
+        case UNAuthorizationStatusAuthorized:    return GLPermissionStatusGranted;
+        case UNAuthorizationStatusProvisional:   return GLPermissionStatusGranted;
+        case UNAuthorizationStatusDenied:        return GLPermissionStatusDenied;
+        case UNAuthorizationStatusEphemeral:     return GLPermissionStatusGranted;
+        case UNAuthorizationStatusNotDetermined:
+        default:                                 return GLPermissionStatusNotDetermined;
+    }
+}
+
+static GLPermissionStatus MapCLAuthorizationStatusValue(CLAuthorizationStatus s)
+{
+    switch (s)
+    {
+        case kCLAuthorizationStatusAuthorizedAlways:
+        case kCLAuthorizationStatusAuthorizedWhenInUse:
+            return GLPermissionStatusGranted;
+        case kCLAuthorizationStatusDenied:
+            return GLPermissionStatusDenied;
+        case kCLAuthorizationStatusRestricted:
+            return GLPermissionStatusRestricted;
+        case kCLAuthorizationStatusNotDetermined:
+        default:
+            return GLPermissionStatusNotDetermined;
+    }
+}
+
+int _GameLoversPermissionsCheck(int permissionId)
+{
+    GLAppPermission p = (GLAppPermission)permissionId;
+    switch (p)
+    {
+        case GLAppPermissionCamera:
+            return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]);
+        case GLAppPermissionMicrophone:
+            return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]);
+        case GLAppPermissionLocationWhenInUse:
+        case GLAppPermissionLocationAlways:
+            return MapCLAuthorizationStatusValue([CLLocationManager authorizationStatus]);
+        case GLAppPermissionPhotoLibrary:
+            if (@available(iOS 14.0, *))
+            {
+                return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]);
+            }
+            return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]);
+        case GLAppPermissionPhotoLibraryAddOnly:
+            if (@available(iOS 14.0, *))
+            {
+                return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelAddOnly]);
+            }
+            return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]);
+        case GLAppPermissionNotifications:
+        {
+            __block GLPermissionStatus result = GLPermissionStatusNotDetermined;
+            dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+            [[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
+                result = MapUNAuthorizationStatus(settings.authorizationStatus);
+                dispatch_semaphore_signal(sem);
+            }];
+            dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
+            return result;
+        }
+    }
+    return GLPermissionStatusNotDetermined;
+}
+
+static void GLSendResult(int requestId, const char *callbackGameObject, const char *callbackMethod, GLPermissionStatus status)
+{
+    NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)status];
+    UnitySendMessage(callbackGameObject, callbackMethod, [payload UTF8String]);
+}
+
+void _GameLoversPermissionsRequest(int permissionId, int requestId, const char *callbackGameObject, const char *callbackMethod)
+{
+    NSString *goName     = [NSString stringWithUTF8String:callbackGameObject];
+    NSString *methodName = [NSString stringWithUTF8String:callbackMethod];
+    GLAppPermission p    = (GLAppPermission)permissionId;
+
+    switch (p)
+    {
+        case GLAppPermissionCamera:
+        {
+            [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
+                GLSendResult(requestId, [goName UTF8String], [methodName UTF8String],
+                             granted ? GLPermissionStatusGranted : GLPermissionStatusDenied);
+            }];
+            return;
+        }
+        case GLAppPermissionMicrophone:
+        {
+            [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
+                GLSendResult(requestId, [goName UTF8String], [methodName UTF8String],
+                             granted ? GLPermissionStatusGranted : GLPermissionStatusDenied);
+            }];
+            return;
+        }
+        case GLAppPermissionLocationWhenInUse:
+        case GLAppPermissionLocationAlways:
+        {
+            if (gLocationDelegates == nil)
+            {
+                gLocationDelegates = [NSMutableArray array];
+            }
+
+            GLLocationDelegate *delegate = [[GLLocationDelegate alloc] init];
+            delegate.requestId          = requestId;
+            delegate.callbackGameObject = goName;
+            delegate.callbackMethod     = methodName;
+            delegate.manager            = [[CLLocationManager alloc] init];
+            delegate.manager.delegate   = delegate;
+            [gLocationDelegates addObject:delegate];
+
+            if (p == GLAppPermissionLocationAlways)
+            {
+                [delegate.manager requestAlwaysAuthorization];
+            }
+            else
+            {
+                [delegate.manager requestWhenInUseAuthorization];
+            }
+            return;
+        }
+        case GLAppPermissionPhotoLibrary:
+        {
+            if (@available(iOS 14.0, *))
+            {
+                [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite handler:^(PHAuthorizationStatus status) {
+                    GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status));
+                }];
+            }
+            else
+            {
+                [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
+                    GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status));
+                }];
+            }
+            return;
+        }
+        case GLAppPermissionPhotoLibraryAddOnly:
+        {
+            if (@available(iOS 14.0, *))
+            {
+                [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly handler:^(PHAuthorizationStatus status) {
+                    GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status));
+                }];
+            }
+            else
+            {
+                [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
+                    GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status));
+                }];
+            }
+            return;
+        }
+        case GLAppPermissionNotifications:
+        {
+            UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound;
+            [[UNUserNotificationCenter currentNotificationCenter]
+                requestAuthorizationWithOptions:options
+                              completionHandler:^(BOOL granted, NSError * _Nullable error) {
+                GLSendResult(requestId, [goName UTF8String], [methodName UTF8String],
+                             granted ? GLPermissionStatusGranted : GLPermissionStatusDenied);
+            }];
+            return;
+        }
+    }
+
+    GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], GLPermissionStatusNotDetermined);
+}
diff --git a/Plugins/iOS/Permissions.m.meta b/Plugins/iOS/Permissions.m.meta
new file mode 100644
index 0000000..959f087
--- /dev/null
+++ b/Plugins/iOS/Permissions.m.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 13fd05a91c602491a95d0e2c2a105ffe
\ No newline at end of file
diff --git a/Plugins/iOS/iOSAudioSession.m b/Plugins/iOS/iOSAudioSession.m
new file mode 100644
index 0000000..6773058
--- /dev/null
+++ b/Plugins/iOS/iOSAudioSession.m
@@ -0,0 +1,21 @@
+#import <AVFoundation/AVFoundation.h>
+
+void _SetAudioSessionPlayback(void)
+{
+    NSError *err = nil;
+    AVAudioSession *session = [AVAudioSession sharedInstance];
+
+    BOOL ok = [session setCategory:AVAudioSessionCategoryPlayback error:&err];
+    if (!ok || err != nil)
+    {
+        NSLog(@"[GameLovers.MobileServices] setCategory:AVAudioSessionCategoryPlayback failed: %@", err);
+        return;
+    }
+
+    err = nil;
+    ok = [session setActive:YES error:&err];
+    if (!ok || err != nil)
+    {
+        NSLog(@"[GameLovers.MobileServices] AVAudioSession setActive:YES failed: %@", err);
+    }
+}
diff --git a/Plugins/iOS/iOSAudioSession.m.meta b/Plugins/iOS/iOSAudioSession.m.meta
new file mode 100644
index 0000000..23d2b17
--- /dev/null
+++ b/Plugins/iOS/iOSAudioSession.m.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65784c3c2c0934a54a68f288799021d0
\ No newline at end of file
diff --git a/README.md b/README.md
index 277163d..6c645b4 100644
--- a/README.md
+++ b/README.md
@@ -1,229 +1,224 @@
-# Package Starter Kit
-
-The purpose of this starter kit is to provide the data structure and development guidelines for new packages meant for the **Unity Package Manager (UPM)**.
-
-## Are you ready to become a package?
-The Package Manager is a work in progress for Unity. Because of that, your package needs to meet these criteria to become an official Unity package:
-- **Your code accesses public Unity C# APIs only.**
-- **Your code doesn't require security, obfuscation, or conditional access control.**
-
-
-## Package structure
-
-```none
-<root>
-  ├── package.json
-  ├── README.md
-  ├── CHANGELOG.md
-  ├── Third Party Notices.md
-  ├── Editor
-  │   ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.asmdef
-  │   └── EditorExample.cs
-  ├── Runtime
-  │   ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.asmdef
-  │   └── RuntimeExample.cs
-  ├── Tests
-  │   ├── .tests.json
-  │   ├── Editor
-  │   │   ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.Tests.asmdef
-  │   │   └── EditorExampleTest.cs
-  │   └── Runtime
-  │        ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Tests.asmdef
-  │        └── RuntimeExampleTest.cs
-  ├── Samples
-  │   └── Example
-  │       ├── .sample.json
-  │       └── SampleExample.cs
-  └── Documentation
-       ├── Native UI.md
-       └── Images
-```
-
-## Develop your package
-Package development works best within the Unity Editor.  Here's how to get started:
-
-1. Enter your package name. The name you choose should contain your default organization followed by the name you typed. For example: `FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi`.
-
-2. [Enter the information](#FillOutFields) for your package in the `package.json` file.
-
-3. [Rename and update](#Asmdef) assembly definition files.
-
-4. [Document](#Doc) your package.
-
-5. [Add samples](#Populate) to your package (code & assets).
-
-6. [Validate](#Valid) your package.
-
-7. [Add tests](#Tests) to your package.
-
-8. Update the `CHANGELOG.md` file. 
-
-    Every new feature or bug fix should have a trace in this file. For more details on the chosen changelog format, see [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
-
-9. Make sure your package [meets all legal requirements](#Legal).
-
-10. Publish your package.
-
-
-
-<a name="FillOutFields"></a>
-### Completing the package manifest
-
-You can either modify the package manifest (`package.json`) file directly in the Inspector or by using an external editor. 
-
-To use the Inspector, select the `package.json` file in the Project browser. The **Package Native UI Manifest** page opens for editing.
-
-Update these required attributes in the `package.json` file: 
-
-| **Attribute name:** | **Description:**                                             |
-| ------------------- | ------------------------------------------------------------ |
-| **name**            | The officially registered package name. This name must conform to the [Unity Package Manager naming convention](https://docs.unity3d.com/Manual/upm-manifestPkg.html#name), which uses reverse domain name notation. For example: <br />`"com.[YourCompanyName].[your-package-name]"` |
-| **displayName**     | A user-friendly name to appear in the Unity Editor (for example, in the Project Browser, the Package Manager window, etc.). For example: <br />`"Terrain Builder SDK"` <br/>__NOTE:__ Use a display name that will help users understand what your package is intended for. |
-| **version**         | The package version number (**'MAJOR.MINOR.PATCH"**). This value must respect [semantic versioning](http://semver.org/). For more information, see [Package version](https://docs.unity3d.com/Manual/upm-manifestPkg.html#pkg-ver) in the Unity User Manual. |
-| **unity**           | The lowest Unity version the package is compatible with. If omitted, the package is considered compatible with all Unity versions. <br /><br />The expected format is "**&lt;MAJOR&gt;.&lt;MINOR&gt;**" (for example, **2018.3**). |
-| **description**     | A brief description of the package. This is the text that appears in the [details view](upm-ui-details) of the Packages window. Any [UTF-8](https://en.wikipedia.org/wiki/UTF-8) character code is supported. This means that you can use special formatting character codes, such as line breaks (**\n**) and bullets (**\u25AA**). |
-
-Update the following recommended fields in file **package.json**:
-
-| **Attribute name:** | **Description:**                                             |
-| ------------------- | ------------------------------------------------------------ |
-| **dependencies**    | A map of package dependencies. Keys are package names, and values are specific versions. They indicate other packages that this package depends on. For more information, see [Dependencies](https://docs.unity3d.com/Manual/upm-dependencies.html) in the Unity User Manual.<br /><br />**NOTE**: The Package Manager does not support range syntax, only **SemVer** versions. |
-| **keywords**        | An array of keywords used by the Package Manager search APIs. This helps users find relevant packages. |
-
-
-
-<a name="Asmdef"></a>
-### Updating the Assembly Definition files
-
-You must associate scripts inside a package to an assembly definition file (.asmdef). Assembly definition files are the Unity equivalent to a C# project in the .NET ecosystem. You must set explicit references in the assembly definition file to other assemblies (whether in the same package or in external packages). See [Assembly Definitions](https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html) for more details.
-
-Use these conventions for naming and storing your assembly definition files to ensure that the compiled assembly filenames follow the [.NET Framework Design Guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/):
-
-* Store Editor-specific code under a root editor assembly definition file:
-
-  `Editor/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.asmdef`
-
-* Store runtime-specific code under a root runtime assembly definition file:
-
-  `Runtime/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.asmdef`
-
-* Configure related test assemblies for your editor and runtime scripts:
-
-  `Tests/Editor/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.Tests.asmdef`
-
-  `Tests/Runtime/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Tests.asmdef`
-
-To get a more general view of a recommended package folder layout, see [Package layout](https://docs.unity3d.com/Manual/cus-layout.html).
-
-
-
-<a name="Doc"></a>
-### Providing documentation
-
-Use the `Documentations~/Native UI.md` documentation file to create preliminary, high-level documentation. This document should introduce users to the features and sample files included in your package.  Your package documentation files will be used to generate online and local docs, available from the Package Manager UI.
-
-**Document your public APIs**
-* All public APIs need to be documented with **XmlDoc**.
-* API documentation is generated from [XmlDoc tags](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/xml-documentation-comments) included with all public APIs found in the package. See [Editor/EditorExample.cs](Editor/EditorExample.cs) for an example.
-
-
-
-
-<a name="Populate"></a>
-### Adding Assets to your package
-
-If your package contains a sample, rename the `Samples/Example` folder, and update the `.sample.json` file in it.
-
-In the case where your package contains multiple samples, you can make a copy of the `Samples/Example` folder for each sample, and update the `.sample.json` file accordingly.
-
-Similar to `.tests.json` file, there is a `"createSeparatePackage"` field in `.sample.json`. If set to true, the CI will create a separate package for the sample.
-
-Delete the `Samples` folder altogether if your package does not need samples.
-
-As of Unity release 2019.1, the Package Manager recognizes the `/Samples` directory in a package. Unity doesn't automatically import samples when a user adds the package to a Project. However, users can click a button in the details view of a package in the **Packages** window to optionally import samples into their `/Assets` directory.
-
-
-
-
-<a name="Valid"></a>
-### Validating your package
-
-Before you publish your package, you need to make sure that it passes all the necessary validation checks by using the Package Validation Suite extension (optional).
-
-Once you install the Validation Suite package, a **Validate** button appears in the details view of a package in the **Packages** window. To install the extension, follow these steps:
-
-1. Point your Project manifest to a staging registry by adding this line to the manifest: 
-    `"registry": "https://staging-packages.unity.com"`
-2. Install the **Package Validation Suite v0.3.0-preview.13** or above from the **Packages** window in Unity. Make sure the package scope is set to **All Packages**, and select **Show preview packages** from the **Advanced** menu.
-3. After installation, a **Validate** button appears in the **Packages** window. Click the button to run a series of tests, then click the **See Results** button for additional information:
-    * If it succeeds, a green bar with a **Success** message appears.
-    * If it fails, a red bar with a **Failed** message appears.
-
-**NOTE:** The validation suite is still in preview.
-
-
-
-
-<a name="Tests"></a>
-### Adding tests to your package
-
-All packages must contain tests.  Tests are essential for Unity to ensure that the package works as expected in different scenarios.
-
-**Editor tests**
-* Write all your Editor Tests in `Tests/Editor`
-
-**Playmode Tests**
-
-* Write all your Playmode Tests in `Tests/Runtime`.
-
-#### Separating the tests from the package
-
-You can create a separate package for the tests, which allows you to exclude a large number of tests and Assets from being published in your main package, while still making it easy to test it.
-
-Open the `Tests/.tests.json` file and set the **createSeparatePackage** attribute:
-
-| **Value to set:** | **Result:**                                                  |
-| ----------------- | ------------------------------------------------------------ |
-| **true**          | CI creates a separate package for these tests. At publish time, the Package Manager adds metadata to link the packages together. |
-| **false**         | Keep the tests as part of the published package.             |
-
-
-
-<a name="Legal"></a>
-### Meeting the legal requirements
-
-You can use the Third Party Notices.md file to make sure your package meets any legal requirements. For example, here is a sample license file from the Unity Timeline package:
-
-```
-Unity Timeline copyright © 2017-2019 Unity Technologies ApS
-
-Licensed under the Unity Companion License for Unity-dependent projects--see [Unity Companion License](http://www.unity3d.com/legal/licenses/Unity_Companion_License).
-
-Unless expressly provided otherwise, the Software under this license is made available strictly on an “AS IS” BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions.
-
-```
-
-
-
-#### Third Party Notices
-
-If your package has third-party elements, you can include the licenses in a Third Party Notices.md file. You can include a **Component Name**, **License Type**, and **Provide License Details** section for each license you want to include. For example:
-
-```
-This package contains third-party software components governed by the license(s) indicated below:
-
-Component Name: Semver
-
-License Type: "MIT"
-
-[SemVer License](https://github.com/myusername/semver/blob/master/License.txt)
-
-Component Name: MyComponent
-
-License Type: "MyLicense"
-
-[MyComponent License](https://www.mycompany.com/licenses/License.txt)
-
-```
-
-**NOTE**: Any URLs you use should point to a location that contains the reproduced license and the copyright information (if applicable).
+# GameLovers Mobile Services
+
+[![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-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)
+
+> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Samples](#samples) | [Related docs](#related-docs) | [Contributing](#contributing)
+
+## Why Use This Package?
+
+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:
+
+| 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 |
+
+**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Tested in real mobile games.
+
+---
+
+## System Requirements
+
+- **[Unity](https://unity.com/download)** 6000.0+ (Unity 6)
+- **[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
+
+| 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
+
+```json
+{
+  "dependencies": {
+    "com.gamelovers.mobileservices": "https://github.com/CoderGamester/com.gamelovers.mobileservices.git"
+  }
+}
+```
+
+---
+
+## Quick Start
+
+### Native UI
+
+```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");
+```
+
+### 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);
+router.MapRoute("/promo/:id", (uri, p) => OpenPromo(p["id"]));
+```
+
+### Haptics
+
+```csharp
+using GameLovers.MobileServices.Haptics;
+
+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
+
+```csharp
+using GameLovers.MobileServices;
+
+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);
+```
+
+---
+
+## Services at a Glance
+
+| Service | Purpose |
+|---------|---------|
+| `NativeUiService` (static) + `INativeUiService` (instance) | Alerts, sheets, toasts, review, share |
+| `INotificationService` / `MobileNotificationService` | Local + remote notifications with channel CRUD, fluent `Schedule()` builder, 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
+
+All Mobile Services editor tooling lives 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).
+- **`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, and the auto-injection build postprocessor.
+
+See [`docs/explorer.md`](docs/explorer.md) and [`docs/build-pipeline.md`](docs/build-pipeline.md) for the full guide.
+
+---
+
+## Samples
+
+Four code-only samples ship with the package — import via `Window > Package Manager > GameLovers.MobileServices > Samples`:
+
+| Sample | Purpose |
+|--------|---------|
+| Mobile Services Playground | Kitchen-sink wiring proof for every subsystem. |
+| Haptics Palette | Designer iteration tool with sequence recorder + replay. |
+| Notifications Scheduler | Channel CRUD + `OperatingMode` lifecycle demo. |
+| Deep Link Router | `MapRoute` pattern + cold-start replay instructions. |
+
+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 |
+| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) |
+| [CHANGELOG.md](CHANGELOG.md) | Version history |
+
+## Contributing
+
+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).
+
+## Support
+
+- **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)
+
+## License
+
+MIT — see [LICENSE.md](LICENSE.md).
diff --git a/README.md.meta b/README.md.meta
index 5f242cd..8254606 100644
--- a/README.md.meta
+++ b/README.md.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 51c2c6a6bb928472188465c9f23bda7c
+guid: 2df90284a1eff40fb9bf2b61178458d0
 TextScriptImporter:
   externalObjects: {}
   userData: 
diff --git a/Runtime.meta b/Runtime.meta
index 524e918..4cfff35 100644
--- a/Runtime.meta
+++ b/Runtime.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: dce1f8fa827854da59dbe9b7a73d289d
+guid: 86bd86435bbf640daa1e69e1aa35330b
 folderAsset: yes
 DefaultImporter:
   externalObjects: {}
diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs
new file mode 100644
index 0000000..f8456bb
--- /dev/null
+++ b/Runtime/AssemblyInfo.cs
@@ -0,0 +1,5 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GameLovers.MobileServices.EditMode.Tests")]
+[assembly: InternalsVisibleTo("GameLovers.MobileServices.PlayMode.Tests")]
+[assembly: InternalsVisibleTo("GameLovers.MobileServices.Editor")]
diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta
new file mode 100644
index 0000000..cebd878
--- /dev/null
+++ b/Runtime/AssemblyInfo.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b2ce3d01102834563a429f601de5100f
\ No newline at end of file
diff --git a/Runtime/Device.meta b/Runtime/Device.meta
new file mode 100644
index 0000000..ef922e9
--- /dev/null
+++ b/Runtime/Device.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: fb52d3931143c4233bb33bf5c22b89b2
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Audio.meta b/Runtime/Device/Audio.meta
new file mode 100644
index 0000000..e1eb13b
--- /dev/null
+++ b/Runtime/Device/Audio.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ae95abd8acc764526a9b8235e8ade907
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Audio/IIosAudioSessionService.cs b/Runtime/Device/Audio/IIosAudioSessionService.cs
new file mode 100644
index 0000000..b65f99c
--- /dev/null
+++ b/Runtime/Device/Audio/IIosAudioSessionService.cs
@@ -0,0 +1,21 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Configures the iOS audio session so app audio is not muted by the device's silent (mute) switch.
+	/// On Android / Editor / unsupported platforms every method is a safe no-op.
+	/// </summary>
+	/// <remarks>
+	/// Also exposed via <see cref="IDeviceService.AudioSession"/> for one-stop discovery.
+	/// </remarks>
+	public interface IIosAudioSessionService
+	{
+		/// <summary>
+		/// Sets the iOS <c>AVAudioSession</c> category to <c>AVAudioSessionCategoryPlayback</c> so audio
+		/// keeps playing even when the device's ringer/silent switch is on.
+		///
+		/// Idempotent and safe to call from any state. Call once at app startup, before any audio plays.
+		/// </summary>
+		void ConfigureForPlayback();
+	}
+}
diff --git a/Runtime/Device/Audio/IIosAudioSessionService.cs.meta b/Runtime/Device/Audio/IIosAudioSessionService.cs.meta
new file mode 100644
index 0000000..e134f04
--- /dev/null
+++ b/Runtime/Device/Audio/IIosAudioSessionService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: af631fa8889354d7fbd90c40aaf926d5
\ No newline at end of file
diff --git a/Runtime/Device/Audio/IosAudioSessionService.cs b/Runtime/Device/Audio/IosAudioSessionService.cs
new file mode 100644
index 0000000..0b7b9cd
--- /dev/null
+++ b/Runtime/Device/Audio/IosAudioSessionService.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Runtime.InteropServices;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public class IosAudioSessionService : IIosAudioSessionService
+	{
+#if UNITY_IOS && !UNITY_EDITOR
+		[DllImport("__Internal")]
+		private static extern void _SetAudioSessionPlayback();
+#endif
+
+		/// <inheritdoc />
+		public void ConfigureForPlayback()
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			try
+			{
+				_SetAudioSessionPlayback();
+				Debug.Log("[GameLovers.MobileServices] iOS audio session configured for playback");
+			}
+			catch (Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Failed to configure iOS audio session: {e.Message}");
+			}
+#else
+			Debug.Log("[GameLovers.MobileServices] IosAudioSessionService.ConfigureForPlayback skipped (not running on iOS device)");
+#endif
+		}
+	}
+}
diff --git a/Runtime/Device/Audio/IosAudioSessionService.cs.meta b/Runtime/Device/Audio/IosAudioSessionService.cs.meta
new file mode 100644
index 0000000..d875f28
--- /dev/null
+++ b/Runtime/Device/Audio/IosAudioSessionService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4a292faee16b24168ad9be1004ee2d4a
\ No newline at end of file
diff --git a/Runtime/Device/DeepLinks.meta b/Runtime/Device/DeepLinks.meta
new file mode 100644
index 0000000..1e183f5
--- /dev/null
+++ b/Runtime/Device/DeepLinks.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ecb503617a8504ed2a2690f30de29602
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/DeepLinks/DeepLinkRouter.cs b/Runtime/Device/DeepLinks/DeepLinkRouter.cs
new file mode 100644
index 0000000..953f840
--- /dev/null
+++ b/Runtime/Device/DeepLinks/DeepLinkRouter.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class DeepLinkRouter : IDeepLinkRouter, IDisposable
+	{
+		private readonly IDeepLinkService _deepLink;
+		private readonly List<Route> _routes = new List<Route>();
+
+		public DeepLinkRouter(IDeepLinkService deepLink)
+		{
+			_deepLink = deepLink ?? throw new ArgumentNullException(nameof(deepLink));
+			_deepLink.OnLinkActivated += OnLinkActivated;
+		}
+
+		public void Dispose()
+		{
+			_deepLink.OnLinkActivated -= OnLinkActivated;
+			_routes.Clear();
+		}
+
+		/// <inheritdoc />
+		public void MapRoute(string pathPattern, Action<Uri, IReadOnlyDictionary<string, string>> handler)
+		{
+			if (string.IsNullOrEmpty(pathPattern)) throw new ArgumentNullException(nameof(pathPattern));
+			if (handler == null) throw new ArgumentNullException(nameof(handler));
+			_routes.Add(new Route(pathPattern, handler));
+		}
+
+		/// <inheritdoc />
+		public void RemoveRoute(string pathPattern)
+		{
+			if (string.IsNullOrEmpty(pathPattern)) return;
+			for (var i = _routes.Count - 1; i >= 0; i--)
+			{
+				if (_routes[i].Pattern == pathPattern)
+				{
+					_routes.RemoveAt(i);
+				}
+			}
+		}
+
+		/// <inheritdoc />
+		public bool TryDispatch(Uri uri)
+		{
+			if (uri == null) return false;
+			foreach (var route in _routes)
+			{
+				if (route.TryMatch(uri, out var captured))
+				{
+					route.Handler(uri, captured);
+					return true;
+				}
+			}
+			return false;
+		}
+
+		private void OnLinkActivated(Uri uri) => TryDispatch(uri);
+
+		private sealed class Route
+		{
+			public readonly string Pattern;
+			public readonly Action<Uri, IReadOnlyDictionary<string, string>> Handler;
+			private readonly string[] _segments;
+
+			public Route(string pattern, Action<Uri, IReadOnlyDictionary<string, string>> handler)
+			{
+				Pattern = pattern;
+				Handler = handler;
+				_segments = SplitPath(pattern);
+			}
+
+			public bool TryMatch(Uri uri, out IReadOnlyDictionary<string, string> captured)
+			{
+				// Build the URI path segments. Treat host as the first segment so myapp://promo/123
+				// matches /promo/:id (most app schemes carry the "type" in host, the "id" in path).
+				var segments = SplitUri(uri);
+
+				if (segments.Length != _segments.Length)
+				{
+					captured = null;
+					return false;
+				}
+
+				var dict = new Dictionary<string, string>();
+				for (var i = 0; i < _segments.Length; i++)
+				{
+					var pat = _segments[i];
+					if (pat.StartsWith(":", StringComparison.Ordinal))
+					{
+						dict[pat.Substring(1)] = segments[i];
+					}
+					else if (!string.Equals(pat, segments[i], StringComparison.OrdinalIgnoreCase))
+					{
+						captured = null;
+						return false;
+					}
+				}
+				captured = dict;
+				return true;
+			}
+
+			private static string[] SplitPath(string pattern)
+			{
+				return pattern.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+			}
+
+			private static string[] SplitUri(Uri uri)
+			{
+				var host = uri.Host ?? string.Empty;
+				var path = uri.AbsolutePath?.Trim('/') ?? string.Empty;
+				var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
+
+				if (string.IsNullOrEmpty(host))
+				{
+					return parts;
+				}
+
+				var combined = new string[parts.Length + 1];
+				combined[0] = host;
+				for (var i = 0; i < parts.Length; i++)
+				{
+					combined[i + 1] = parts[i];
+				}
+				return combined;
+			}
+		}
+	}
+}
diff --git a/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta b/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta
new file mode 100644
index 0000000..1ce7717
--- /dev/null
+++ b/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 87b2e595f2a2a4f2e98ee67d843d2df6
\ No newline at end of file
diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs b/Runtime/Device/DeepLinks/DeepLinkService.cs
new file mode 100644
index 0000000..ebdfbfa
--- /dev/null
+++ b/Runtime/Device/DeepLinks/DeepLinkService.cs
@@ -0,0 +1,91 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class DeepLinkService : IDeepLinkService, IDisposable
+	{
+		private Action<Uri> _onLinkActivated;
+		private Uri _pendingColdStartLink;
+
+		/// <inheritdoc />
+		public Uri PendingColdStartLink => _pendingColdStartLink;
+
+		/// <inheritdoc />
+		public event Action<Uri> OnLinkActivated
+		{
+			add
+			{
+				_onLinkActivated += value;
+				if (_pendingColdStartLink == null)
+				{
+					return;
+				}
+				var pending = _pendingColdStartLink;
+				_pendingColdStartLink = null;
+				value(pending);
+			}
+			remove => _onLinkActivated -= value;
+		}
+
+		public DeepLinkService()
+		{
+			Application.deepLinkActivated += OnDeepLinkActivated;
+
+			// If the app was cold-launched with a deep link, Application.absoluteURL is non-empty
+			// before any subscriber attaches. Capture it here and replay on the first subscription.
+			if (!string.IsNullOrEmpty(Application.absoluteURL))
+			{
+				_pendingColdStartLink = TryParse(Application.absoluteURL);
+			}
+		}
+
+		public void Dispose()
+		{
+			Application.deepLinkActivated -= OnDeepLinkActivated;
+			_onLinkActivated = null;
+			_pendingColdStartLink = null;
+		}
+
+		private void OnDeepLinkActivated(string url)
+		{
+			var parsed = TryParse(url);
+			if (parsed == null)
+			{
+				return;
+			}
+
+			// Runtime delivery supersedes / consumes the pending cold-start link.
+			_pendingColdStartLink = null;
+			_onLinkActivated?.Invoke(parsed);
+		}
+
+#if UNITY_EDITOR
+		/// <summary>
+		/// Editor-only simulator hook. Mimics what <c>Application.deepLinkActivated</c> would do
+		/// when the OS hands the app a link at runtime — supersedes any pending cold-start link
+		/// and dispatches to all current subscribers.
+		/// </summary>
+		internal void SimulateLinkActivated(Uri uri)
+		{
+			if (uri == null)
+			{
+				return;
+			}
+			_pendingColdStartLink = null;
+			_onLinkActivated?.Invoke(uri);
+		}
+#endif
+
+		private static Uri TryParse(string url)
+		{
+			if (string.IsNullOrEmpty(url))
+			{
+				return null;
+			}
+			return Uri.TryCreate(url, UriKind.Absolute, out var parsed) ? parsed : null;
+		}
+	}
+}
diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs.meta b/Runtime/Device/DeepLinks/DeepLinkService.cs.meta
new file mode 100644
index 0000000..e2f3f31
--- /dev/null
+++ b/Runtime/Device/DeepLinks/DeepLinkService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3541bcc9b6e0649409dbe83108a26535
\ No newline at end of file
diff --git a/Runtime/Device/DeepLinks/IDeepLinkRouter.cs b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs
new file mode 100644
index 0000000..850bc88
--- /dev/null
+++ b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Routing layer over <see cref="IDeepLinkService"/>. Maps URI path patterns to handlers and
+	/// dispatches each incoming deep link to the first matching route.
+	/// </summary>
+	/// <remarks>
+	/// <para>Path-pattern syntax is intentionally minimal — the goal is to remove the per-consumer
+	/// switch-on-segments boilerplate, not to grow into a full URL-routing DSL:</para>
+	/// <list type="bullet">
+	/// <item>Literal segments match exactly. <c>/settings</c> matches <c>myapp://settings</c>.</item>
+	/// <item>Segments prefixed with <c>:</c> capture into the params dict. <c>/promo/:id</c> matches
+	/// <c>myapp://promo/abc123</c> yielding <c>{ "id": "abc123" }</c>.</item>
+	/// <item>Routes are checked in registration order; the first match wins.</item>
+	/// </list>
+	/// <para>The router subscribes once to <see cref="IDeepLinkService.OnLinkActivated"/> at
+	/// construction; consumers should hold the router instance for the lifetime of the app to avoid
+	/// re-subscription churn.</para>
+	/// </remarks>
+	public interface IDeepLinkRouter
+	{
+		/// <summary>
+		/// Registers a route. Path-pattern syntax: literal segments match exactly, segments prefixed
+		/// with <c>:</c> capture into the handler's <c>params</c> argument.
+		/// </summary>
+		void MapRoute(string pathPattern, Action<Uri, IReadOnlyDictionary<string, string>> handler);
+
+		/// <summary>Removes the route previously registered with <paramref name="pathPattern"/>. No-op if absent.</summary>
+		void RemoveRoute(string pathPattern);
+
+		/// <summary>
+		/// Attempts to dispatch <paramref name="uri"/> through the registered routes. Returns
+		/// <c>true</c> when a route matched and its handler was invoked.
+		/// </summary>
+		bool TryDispatch(Uri uri);
+	}
+}
diff --git a/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta
new file mode 100644
index 0000000..03e270c
--- /dev/null
+++ b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 989c6742551db450f972afbffc51c88e
\ No newline at end of file
diff --git a/Runtime/Device/DeepLinks/IDeepLinkService.cs b/Runtime/Device/DeepLinks/IDeepLinkService.cs
new file mode 100644
index 0000000..b280688
--- /dev/null
+++ b/Runtime/Device/DeepLinks/IDeepLinkService.cs
@@ -0,0 +1,25 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Wraps <c>Application.deepLinkActivated</c> with cold-start link queueing — links the OS handed
+	/// the app at launch are not lost if the first <see cref="OnLinkActivated"/> subscriber attaches
+	/// after the event has already fired.
+	/// </summary>
+	public interface IDeepLinkService
+	{
+		/// <summary>
+		/// Fires whenever the OS delivers a deep link. If the app was cold-launched with a link and
+		/// no subscriber was attached yet, the link is replayed to the first subscriber.
+		/// </summary>
+		event Action<Uri> OnLinkActivated;
+
+		/// <summary>
+		/// The cold-start link, if any. Reads as <c>null</c> after the first <see cref="OnLinkActivated"/>
+		/// subscriber consumes it (or after the first delivered runtime link, whichever comes first).
+		/// </summary>
+		Uri PendingColdStartLink { get; }
+	}
+}
diff --git a/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta b/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta
new file mode 100644
index 0000000..f3fff49
--- /dev/null
+++ b/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9307ee40aeeb1433a83af064d917b910
\ No newline at end of file
diff --git a/Runtime/Device/DeviceService.cs b/Runtime/Device/DeviceService.cs
new file mode 100644
index 0000000..f4b6986
--- /dev/null
+++ b/Runtime/Device/DeviceService.cs
@@ -0,0 +1,85 @@
+using System;
+using GameLovers.MobileServices.Device.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class DeviceService : IDeviceService, IDisposable
+	{
+		/// <inheritdoc />
+		public ISafeAreaService SafeArea { get; }
+		/// <inheritdoc />
+		public IScreenWakeService ScreenWake { get; }
+		/// <inheritdoc />
+		public IBatteryService Battery { get; }
+		/// <inheritdoc />
+		public IIosAudioSessionService AudioSession { get; }
+		/// <inheritdoc />
+		public IPermissionsService Permissions { get; }
+		/// <inheritdoc />
+		public IAttService Att { get; }
+		/// <inheritdoc />
+		public IDeepLinkService DeepLink { get; }
+
+		/// <summary>
+		/// Constructs the umbrella with the default child implementations for the current platform.
+		/// The host-dependent children (SafeArea, Battery) share a single
+		/// <see cref="DeviceServicesHost"/> spawned by this constructor — no extra GameObjects.
+		/// </summary>
+		public DeviceService() : this(BuildDefaults()) { }
+
+		/// <summary>
+		/// Constructs the umbrella with injected children. Used by tests to supply mocks.
+		/// Children that implement <see cref="IDisposable"/> will be disposed by <see cref="Dispose"/>.
+		/// </summary>
+		public DeviceService(
+			ISafeAreaService safeArea,
+			IScreenWakeService screenWake,
+			IBatteryService battery,
+			IIosAudioSessionService audioSession,
+			IPermissionsService permissions,
+			IAttService att,
+			IDeepLinkService deepLink)
+		{
+			SafeArea = safeArea;
+			ScreenWake = screenWake;
+			Battery = battery;
+			AudioSession = audioSession;
+			Permissions = permissions;
+			Att = att;
+			DeepLink = deepLink;
+		}
+
+		// 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,
+			IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) defaults)
+			: this(defaults.Item1, defaults.Item2, defaults.Item3,
+				   defaults.Item4, defaults.Item5, defaults.Item6, defaults.Item7)
+		{
+		}
+
+		private static (ISafeAreaService, IScreenWakeService, IBatteryService,
+			IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) BuildDefaults()
+		{
+			var host = DeviceServicesHost.Instance;
+			return (
+				new SafeAreaService(host),
+				new ScreenWakeService(),
+				new BatteryService(host),
+				new IosAudioSessionService(),
+				new PermissionsService(),
+				new AttService(),
+				new DeepLinkService());
+		}
+
+		public void Dispose()
+		{
+			(SafeArea as IDisposable)?.Dispose();
+			(Battery as IDisposable)?.Dispose();
+			(DeepLink as IDisposable)?.Dispose();
+		}
+	}
+}
diff --git a/Runtime/Device/DeviceService.cs.meta b/Runtime/Device/DeviceService.cs.meta
new file mode 100644
index 0000000..a715a06
--- /dev/null
+++ b/Runtime/Device/DeviceService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5449557811d2b4b0a8e953804773afff
\ No newline at end of file
diff --git a/Runtime/Device/IDeviceService.cs b/Runtime/Device/IDeviceService.cs
new file mode 100644
index 0000000..aeb05b9
--- /dev/null
+++ b/Runtime/Device/IDeviceService.cs
@@ -0,0 +1,32 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// 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.
+	/// </summary>
+	public interface IDeviceService
+	{
+		/// <summary>Display safe-area events (notch, dynamic island, orientation).</summary>
+		ISafeAreaService SafeArea { get; }
+
+		/// <summary>Toggle <c>Screen.sleepTimeout</c> (keep the screen awake).</summary>
+		IScreenWakeService ScreenWake { get; }
+
+		/// <summary>Battery level / status / low-power-mode awareness.</summary>
+		IBatteryService Battery { get; }
+
+		/// <summary>iOS audio session category override (silent-switch). No-op elsewhere.</summary>
+		IIosAudioSessionService AudioSession { get; }
+
+		/// <summary>Unified iOS+Android runtime permissions (Camera, Mic, Location, Photos, Notifications).</summary>
+		IPermissionsService Permissions { get; }
+
+		/// <summary>iOS App Tracking Transparency (no-op on Android / Editor / unsupported).</summary>
+		IAttService Att { get; }
+
+		/// <summary>OS deep link delivery with cold-start link queueing.</summary>
+		IDeepLinkService DeepLink { get; }
+	}
+}
diff --git a/Runtime/Device/IDeviceService.cs.meta b/Runtime/Device/IDeviceService.cs.meta
new file mode 100644
index 0000000..d5530f1
--- /dev/null
+++ b/Runtime/Device/IDeviceService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 88f06b1d4f8da428884c7beab0afeacf
\ No newline at end of file
diff --git a/Runtime/Device/Internal.meta b/Runtime/Device/Internal.meta
new file mode 100644
index 0000000..ea770e1
--- /dev/null
+++ b/Runtime/Device/Internal.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 39dbc9b9b6b814061b06d63d0319e60c
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Internal/DeviceServicesHost.cs b/Runtime/Device/Internal/DeviceServicesHost.cs
new file mode 100644
index 0000000..d58df23
--- /dev/null
+++ b/Runtime/Device/Internal/DeviceServicesHost.cs
@@ -0,0 +1,152 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device.Internal
+{
+	/// <summary>
+	/// Internal MonoBehaviour shared by all event-driven Device services (SafeArea, Battery).
+	/// Spawned lazily on first use, marked <c>DontDestroyOnLoad</c>. Exists so the runtime cost of the
+	/// Device subsystem is one auto-spawned GameObject instead of one per service.
+	/// </summary>
+	/// <remarks>
+	/// Although every member of this class is logically <c>internal</c>, the
+	/// <see cref="OnIosLowPowerModeChanged"/> entry point is kept <c>public</c> because it is reached
+	/// by Unity's <c>UnitySendMessage</c> dispatcher from the iOS native bridge
+	/// (<c>Plugins/iOS/Battery.m</c>). Keeping it explicitly <c>public</c> documents that contract
+	/// and avoids any future change to Unity's reflection visibility defaults breaking the bridge.
+	/// </remarks>
+	internal sealed class DeviceServicesHost : MonoBehaviour
+	{
+		// Per-service callback registration. Each callback is invoked once per Update at most;
+		// each service is responsible for diffing and firing its own events from the callback.
+		private event Action _onLateUpdate;
+		private event Action _onSecondTick;
+		private event Action<bool> _onApplicationFocusChanged;
+		private event Action _onIosLowPowerModeChanged;
+
+		private float _secondAccumulator;
+
+		private static DeviceServicesHost _instance;
+
+		/// <summary>Returns the shared instance, spawning the host GameObject on first call.</summary>
+		internal static DeviceServicesHost Instance
+		{
+			get
+			{
+				if (_instance != null)
+				{
+					return _instance;
+				}
+
+				var go = new GameObject("DeviceServicesHost");
+				DontDestroyOnLoad(go);
+				_instance = go.AddComponent<DeviceServicesHost>();
+				return _instance;
+			}
+		}
+
+		/// <summary>
+		/// Tears down the singleton: destroys the GameObject (if any) and clears the static reference.
+		/// Intended for EditMode tests that need a clean state between runs.
+		/// </summary>
+		internal static void ResetForTests()
+		{
+			if (_instance == null)
+			{
+				return;
+			}
+
+			var go = _instance.gameObject;
+			_instance = null;
+
+			if (Application.isPlaying)
+			{
+				Destroy(go);
+			}
+			else
+			{
+				DestroyImmediate(go);
+			}
+		}
+
+		/// <summary>Subscribes to a per-LateUpdate poll tick.</summary>
+		internal void RegisterLateUpdate(Action callback)
+		{
+			_onLateUpdate += callback;
+		}
+
+		internal void UnregisterLateUpdate(Action callback)
+		{
+			_onLateUpdate -= callback;
+		}
+
+		/// <summary>Subscribes to a roughly-once-per-second tick (cheaper than LateUpdate; useful for connectivity polling).</summary>
+		internal void RegisterSecondTick(Action callback)
+		{
+			_onSecondTick += callback;
+		}
+
+		internal void UnregisterSecondTick(Action callback)
+		{
+			_onSecondTick -= callback;
+		}
+
+		/// <summary>Subscribes to <c>OnApplicationFocus(bool focused)</c>.</summary>
+		internal void RegisterFocusChanged(Action<bool> callback)
+		{
+			_onApplicationFocusChanged += callback;
+		}
+
+		internal void UnregisterFocusChanged(Action<bool> callback)
+		{
+			_onApplicationFocusChanged -= callback;
+		}
+
+		/// <summary>Subscribes to the iOS low-power-mode change signal sourced from the native bridge.</summary>
+		internal void RegisterIosLowPowerModeChanged(Action callback)
+		{
+			_onIosLowPowerModeChanged += callback;
+		}
+
+		internal void UnregisterIosLowPowerModeChanged(Action callback)
+		{
+			_onIosLowPowerModeChanged -= callback;
+		}
+
+		// MUST stay public: invoked by Unity's UnitySendMessage from Plugins/iOS/Battery.m as
+		// UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", "").
+		// Renaming this method or its enclosing GameObject requires updating the iOS .m file.
+		// ReSharper disable once UnusedMember.Global
+		// ReSharper disable once InconsistentNaming
+		public void OnIosLowPowerModeChanged(string _)
+		{
+			_onIosLowPowerModeChanged?.Invoke();
+		}
+
+		private void LateUpdate()
+		{
+			_onLateUpdate?.Invoke();
+
+			_secondAccumulator += Time.unscaledDeltaTime;
+			if (_secondAccumulator >= 1f)
+			{
+				_secondAccumulator = 0f;
+				_onSecondTick?.Invoke();
+			}
+		}
+
+		private void OnApplicationFocus(bool focused)
+		{
+			_onApplicationFocusChanged?.Invoke(focused);
+		}
+
+		private void OnDestroy()
+		{
+			if (_instance == this)
+			{
+				_instance = null;
+			}
+		}
+	}
+}
diff --git a/Runtime/Device/Internal/DeviceServicesHost.cs.meta b/Runtime/Device/Internal/DeviceServicesHost.cs.meta
new file mode 100644
index 0000000..12f7983
--- /dev/null
+++ b/Runtime/Device/Internal/DeviceServicesHost.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8f7ad0af7b8cf45eca36a4853ba2cce3
\ No newline at end of file
diff --git a/Runtime/Device/Permissions.meta b/Runtime/Device/Permissions.meta
new file mode 100644
index 0000000..1ecd1b6
--- /dev/null
+++ b/Runtime/Device/Permissions.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e7031fd4089a9460b9883ce24bc5d668
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Permissions/IPermissionsService.cs b/Runtime/Device/Permissions/IPermissionsService.cs
new file mode 100644
index 0000000..4cc640c
--- /dev/null
+++ b/Runtime/Device/Permissions/IPermissionsService.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Cross-platform permissions catalog.
+	/// </summary>
+	public enum AppPermission
+	{
+		Camera,
+		Microphone,
+		LocationWhenInUse,
+		LocationAlways,
+		PhotoLibrary,
+		PhotoLibraryAddOnly,
+		Notifications,
+	}
+
+	/// <summary>Result of a permission check / request.</summary>
+	public enum PermissionStatus
+	{
+		NotDetermined,
+		Denied,
+		Granted,
+		Restricted,
+	}
+
+	/// <summary>
+	/// Unified iOS+Android runtime-permissions service.
+	/// </summary>
+	/// <remarks>
+	/// Uses <see cref="Task{TResult}"/> rather than <c>UniTask</c> to avoid pulling in a new package dependency.
+	/// </remarks>
+	public interface IPermissionsService
+	{
+		/// <summary>Returns the current status without prompting the user. Synchronous.</summary>
+		PermissionStatus Check(AppPermission permission);
+
+		/// <summary>Requests the permission, prompting the user if not yet determined. Idempotent if already granted/denied.</summary>
+		Task<PermissionStatus> RequestAsync(AppPermission permission);
+
+		/// <summary>
+		/// Convenience for the common multi-permission flows (e.g. camera+mic for video chat). Awaits
+		/// each <see cref="RequestAsync(AppPermission)"/> sequentially — iOS prompts cannot stack — and
+		/// returns a dictionary keyed by permission.
+		/// </summary>
+		/// <remarks>
+		/// Default interface method — implementations may override, but the default behaviour is
+		/// expected to be sufficient for the vast majority of consumers.
+		/// </remarks>
+		async Task<IReadOnlyDictionary<AppPermission, PermissionStatus>> RequestAsync(params AppPermission[] permissions)
+		{
+			var result = new Dictionary<AppPermission, PermissionStatus>();
+			if (permissions == null) return result;
+			foreach (var p in permissions)
+			{
+				result[p] = await RequestAsync(p);
+			}
+			return result;
+		}
+	}
+}
diff --git a/Runtime/Device/Permissions/IPermissionsService.cs.meta b/Runtime/Device/Permissions/IPermissionsService.cs.meta
new file mode 100644
index 0000000..4b8dd46
--- /dev/null
+++ b/Runtime/Device/Permissions/IPermissionsService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 42aa64b32bcbc479c819cae6e6505f36
\ No newline at end of file
diff --git a/Runtime/Device/Permissions/Internal.meta b/Runtime/Device/Permissions/Internal.meta
new file mode 100644
index 0000000..0065360
--- /dev/null
+++ b/Runtime/Device/Permissions/Internal.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8a029d609b7264f51be90ed20f6c91ae
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs
new file mode 100644
index 0000000..8365395
--- /dev/null
+++ b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device.Internal
+{
+	/// <summary>
+	/// Internal MonoBehaviour that receives async permission results from the iOS native bridge via
+	/// <c>UnitySendMessage</c>. Resolves the matching pending <see cref="TaskCompletionSource{T}"/>.
+	/// </summary>
+	internal sealed class PermissionsCallbackReceiver : MonoBehaviour
+	{
+		private static PermissionsCallbackReceiver _instance;
+		private readonly Dictionary<int, TaskCompletionSource<PermissionStatus>> _pending =
+			new Dictionary<int, TaskCompletionSource<PermissionStatus>>();
+		private int _nextId = 1;
+
+		public static PermissionsCallbackReceiver Instance
+		{
+			get
+			{
+				if (_instance != null)
+				{
+					return _instance;
+				}
+
+				var go = new GameObject("PermissionsCallbackReceiver");
+				DontDestroyOnLoad(go);
+				_instance = go.AddComponent<PermissionsCallbackReceiver>();
+				return _instance;
+			}
+		}
+
+		/// <summary>Registers a TCS and returns the request id to pass to the native bridge.</summary>
+		public int Register(TaskCompletionSource<PermissionStatus> tcs)
+		{
+			var id = _nextId++;
+			_pending[id] = tcs;
+			return id;
+		}
+
+		// Native iOS bridge calls UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", "<id>:<status>")
+		// where status is the int value of PermissionStatus.
+		// ReSharper disable once UnusedMember.Global
+		// ReSharper disable once InconsistentNaming
+		public void OnPermissionResult(string payload)
+		{
+			try
+			{
+				var sep = payload.IndexOf(':');
+				if (sep <= 0)
+				{
+					return;
+				}
+				var idText = payload.Substring(0, sep);
+				var statusText = payload.Substring(sep + 1);
+
+				if (!int.TryParse(idText, out var id) ||
+				    !int.TryParse(statusText, out var statusInt))
+				{
+					return;
+				}
+
+				if (!_pending.TryGetValue(id, out var tcs))
+				{
+					return;
+				}
+
+				_pending.Remove(id);
+
+				var status = (PermissionStatus)statusInt;
+				tcs.TrySetResult(status);
+			}
+			catch (Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] PermissionsCallbackReceiver failed to parse '{payload}': {e.Message}");
+			}
+		}
+
+		private void OnDestroy()
+		{
+			if (_instance == this)
+			{
+				_instance = null;
+			}
+
+			foreach (var tcs in _pending.Values)
+			{
+				tcs.TrySetCanceled();
+			}
+			_pending.Clear();
+		}
+	}
+}
diff --git a/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta
new file mode 100644
index 0000000..deaabd1
--- /dev/null
+++ b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d72e80e5de72440cbb3195cc5afd331a
\ No newline at end of file
diff --git a/Runtime/Device/Permissions/PermissionsService.cs b/Runtime/Device/Permissions/PermissionsService.cs
new file mode 100644
index 0000000..b69dfd2
--- /dev/null
+++ b/Runtime/Device/Permissions/PermissionsService.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device.Internal;
+using UnityEngine;
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+using UnityEngine.Android;
+#endif
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class PermissionsService : IPermissionsService
+	{
+#if UNITY_IOS && !UNITY_EDITOR
+		[DllImport("__Internal")] private static extern int  _GameLoversPermissionsCheck(int permissionId);
+		[DllImport("__Internal")] private static extern void _GameLoversPermissionsRequest(int permissionId, int requestId, string callbackGameObject, string callbackMethod);
+#endif
+
+#if UNITY_EDITOR
+		// Editor-only override hooks consumed by EditorPlatformSimulator so the Device Simulator can
+		// model the real OS flow in the editor. When set, the editor short-circuit paths consult these
+		// instead of returning the default Granted; when unset, the default is unchanged and runtime
+		// non-Editor builds carry none of this surface.
+		//   - EditorCheckOverride / EditorRequestOverride: synchronous reads (the simulated persisted decision).
+		//   - EditorRequestAsyncOverride: the first request for a NotDetermined permission shows a prompt and the
+		//     returned Task completes when the user answers; takes precedence over the synchronous EditorRequestOverride.
+		internal static Func<AppPermission, PermissionStatus> EditorCheckOverride;
+		internal static Func<AppPermission, PermissionStatus> EditorRequestOverride;
+		internal static Func<AppPermission, Task<PermissionStatus>> EditorRequestAsyncOverride;
+#endif
+
+		/// <inheritdoc />
+		public PermissionStatus Check(AppPermission permission)
+		{
+#if UNITY_EDITOR
+			var over = EditorCheckOverride;
+			return over != null ? over(permission) : PermissionStatus.Granted;
+#elif UNITY_IOS
+			return (PermissionStatus)_GameLoversPermissionsCheck((int)permission);
+#elif UNITY_ANDROID
+			return CheckAndroid(permission);
+#else
+			return PermissionStatus.NotDetermined;
+#endif
+		}
+
+		/// <summary>
+		/// Snapshot of <see cref="Check"/> across every <see cref="AppPermission"/> value. Used by
+		/// the Device Simulator panel's Permissions foldout to render the per-permission status grid
+		/// without forcing callers to iterate the enum themselves.
+		/// </summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal IReadOnlyDictionary<AppPermission, PermissionStatus> CheckSnapshot()
+		{
+			var dict = new Dictionary<AppPermission, PermissionStatus>();
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				dict[p] = Check(p);
+			}
+			return dict;
+		}
+
+		/// <inheritdoc />
+		public Task<PermissionStatus> RequestAsync(AppPermission permission)
+		{
+#if UNITY_EDITOR
+			var asyncOver = EditorRequestAsyncOverride;
+			if (asyncOver != null)
+			{
+				return asyncOver(permission);
+			}
+			var over = EditorRequestOverride;
+			return Task.FromResult(over != null ? over(permission) : PermissionStatus.Granted);
+#elif UNITY_IOS
+			var tcs = new TaskCompletionSource<PermissionStatus>();
+			var id = PermissionsCallbackReceiver.Instance.Register(tcs);
+			_GameLoversPermissionsRequest((int)permission, id, "PermissionsCallbackReceiver", "OnPermissionResult");
+			return tcs.Task;
+#elif UNITY_ANDROID
+			return RequestAndroidAsync(permission);
+#else
+			return Task.FromResult(PermissionStatus.NotDetermined);
+#endif
+		}
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+		private static string AndroidManifestPermission(AppPermission permission)
+		{
+			return permission switch
+			{
+				AppPermission.Camera               => Permission.Camera,
+				AppPermission.Microphone           => Permission.Microphone,
+				AppPermission.LocationWhenInUse    => Permission.FineLocation,
+				AppPermission.LocationAlways       => Permission.FineLocation,
+				AppPermission.PhotoLibrary         => "android.permission.READ_MEDIA_IMAGES",
+				AppPermission.PhotoLibraryAddOnly  => "android.permission.READ_MEDIA_IMAGES",
+				AppPermission.Notifications        => "android.permission.POST_NOTIFICATIONS",
+				_                                  => null
+			};
+		}
+
+		private static PermissionStatus CheckAndroid(AppPermission permission)
+		{
+			var manifestId = AndroidManifestPermission(permission);
+			if (string.IsNullOrEmpty(manifestId))
+			{
+				return PermissionStatus.NotDetermined;
+			}
+			return Permission.HasUserAuthorizedPermission(manifestId)
+				? PermissionStatus.Granted
+				: PermissionStatus.NotDetermined;
+		}
+
+		private static Task<PermissionStatus> RequestAndroidAsync(AppPermission permission)
+		{
+			var manifestId = AndroidManifestPermission(permission);
+			if (string.IsNullOrEmpty(manifestId))
+			{
+				return Task.FromResult(PermissionStatus.NotDetermined);
+			}
+
+			if (Permission.HasUserAuthorizedPermission(manifestId))
+			{
+				return Task.FromResult(PermissionStatus.Granted);
+			}
+
+			var tcs = new TaskCompletionSource<PermissionStatus>();
+			var callbacks = new PermissionCallbacks();
+			callbacks.PermissionGranted             += _ => tcs.TrySetResult(PermissionStatus.Granted);
+			callbacks.PermissionDenied              += _ => tcs.TrySetResult(PermissionStatus.Denied);
+			callbacks.PermissionDeniedAndDontAskAgain += _ => tcs.TrySetResult(PermissionStatus.Denied);
+
+			Permission.RequestUserPermission(manifestId, callbacks);
+			return tcs.Task;
+		}
+#endif
+	}
+}
diff --git a/Runtime/Device/Permissions/PermissionsService.cs.meta b/Runtime/Device/Permissions/PermissionsService.cs.meta
new file mode 100644
index 0000000..078d1a7
--- /dev/null
+++ b/Runtime/Device/Permissions/PermissionsService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5f1dbb6576c374171a4bd1ece4171526
\ No newline at end of file
diff --git a/Runtime/Device/State.meta b/Runtime/Device/State.meta
new file mode 100644
index 0000000..ea29c41
--- /dev/null
+++ b/Runtime/Device/State.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c310f548e14f146aaa345db1faebc5e2
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/State/BatteryService.cs b/Runtime/Device/State/BatteryService.cs
new file mode 100644
index 0000000..74b79b3
--- /dev/null
+++ b/Runtime/Device/State/BatteryService.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Runtime.InteropServices;
+using GameLovers.MobileServices.Device.Internal;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class BatteryService : IBatteryService, IDisposable
+	{
+#if UNITY_IOS && !UNITY_EDITOR
+		[DllImport("__Internal")] private static extern bool _GameLoversBatteryIsLowPowerModeEnabled();
+		[DllImport("__Internal")] private static extern void _GameLoversBatteryStartObservingLowPowerMode();
+		[DllImport("__Internal")] private static extern void _GameLoversBatteryStopObservingLowPowerMode();
+#endif
+
+		private const float LevelChangeThreshold = 0.01f;
+
+		private readonly DeviceServicesHost _host;
+
+		private float _lastLevel;
+		private BatteryStatus _lastStatus;
+		private bool _lastLowPowerMode;
+
+		/// <inheritdoc />
+		public float Level => SystemInfo.batteryLevel;
+
+		/// <inheritdoc />
+		public BatteryStatus Status => SystemInfo.batteryStatus;
+
+		/// <inheritdoc />
+		public bool IsLowPowerMode { get; private set; }
+
+		/// <inheritdoc />
+		public event Action OnLevelChanged;
+		/// <inheritdoc />
+		public event Action OnStatusChanged;
+		/// <inheritdoc />
+		public event Action OnLowPowerModeChanged;
+
+		/// <summary>Default ctor uses the package-wide singleton host (<see cref="DeviceServicesHost.Instance"/>).</summary>
+		public BatteryService() : this(DeviceServicesHost.Instance) { }
+
+		/// <summary>
+		/// Test/DI overload that accepts an explicit host. Used by <see cref="DeviceService"/> to
+		/// share a single host instance across the umbrella's children, and by tests that want
+		/// deterministic host lifetime.
+		/// </summary>
+		internal BatteryService(DeviceServicesHost host)
+		{
+			_host = host;
+			_lastLevel = Level;
+			_lastStatus = Status;
+			IsLowPowerMode = QueryLowPowerMode();
+			_lastLowPowerMode = IsLowPowerMode;
+
+			_host.RegisterSecondTick(OnSecondTick);
+			_host.RegisterFocusChanged(OnFocusChanged);
+			_host.RegisterIosLowPowerModeChanged(OnIosLowPowerModeChanged);
+
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversBatteryStartObservingLowPowerMode();
+#endif
+		}
+
+		public void Dispose()
+		{
+			_host.UnregisterSecondTick(OnSecondTick);
+			_host.UnregisterFocusChanged(OnFocusChanged);
+			_host.UnregisterIosLowPowerModeChanged(OnIosLowPowerModeChanged);
+
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversBatteryStopObservingLowPowerMode();
+#endif
+		}
+
+		private void OnSecondTick()
+		{
+			var current = Level;
+			if (Mathf.Abs(current - _lastLevel) >= LevelChangeThreshold)
+			{
+				_lastLevel = current;
+				OnLevelChanged?.Invoke();
+			}
+
+			var status = Status;
+			if (status != _lastStatus)
+			{
+				_lastStatus = status;
+				OnStatusChanged?.Invoke();
+			}
+		}
+
+		private void OnFocusChanged(bool focused)
+		{
+			if (!focused)
+			{
+				return;
+			}
+			RefreshLowPowerMode();
+		}
+
+		private void OnIosLowPowerModeChanged()
+		{
+			RefreshLowPowerMode();
+		}
+
+		private void RefreshLowPowerMode()
+		{
+			var current = QueryLowPowerMode();
+			if (current == _lastLowPowerMode)
+			{
+				return;
+			}
+			_lastLowPowerMode = current;
+			IsLowPowerMode = current;
+			OnLowPowerModeChanged?.Invoke();
+		}
+
+		private static bool QueryLowPowerMode()
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			try
+			{
+				return _GameLoversBatteryIsLowPowerModeEnabled();
+			}
+			catch
+			{
+				return false;
+			}
+#elif UNITY_ANDROID && !UNITY_EDITOR
+			try
+			{
+				using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+				using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
+				using var powerManager = activity.Call<AndroidJavaObject>("getSystemService", "power");
+				return powerManager.Call<bool>("isPowerSaveMode");
+			}
+			catch
+			{
+				return false;
+			}
+#elif UNITY_EDITOR
+			return EditorLowPowerModeOverride;
+#else
+			return false;
+#endif
+		}
+
+#if UNITY_EDITOR
+		// Editor-only simulator hook. EditorPlatformSimulator flips this then calls
+		// SimulateLowPowerModeChanged() to fan the change through to subscribers, mirroring
+		// what NSProcessInfoPowerStateDidChangeNotification would do on iOS.
+		internal static bool EditorLowPowerModeOverride;
+
+		/// <summary>
+		/// Editor-only test/simulator hook. Runs the LPM refresh path that would normally be
+		/// driven by the iOS bridge (<c>UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", "")</c>),
+		/// re-reading <see cref="EditorLowPowerModeOverride"/> and firing
+		/// <see cref="OnLowPowerModeChanged"/> on transition.
+		/// </summary>
+		internal void SimulateLowPowerModeChanged()
+		{
+			RefreshLowPowerMode();
+		}
+#endif
+	}
+}
diff --git a/Runtime/Device/State/BatteryService.cs.meta b/Runtime/Device/State/BatteryService.cs.meta
new file mode 100644
index 0000000..08b1256
--- /dev/null
+++ b/Runtime/Device/State/BatteryService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 533e48499815748e5b47d09fd3cccff6
\ No newline at end of file
diff --git a/Runtime/Device/State/IBatteryService.cs b/Runtime/Device/State/IBatteryService.cs
new file mode 100644
index 0000000..e106fbe
--- /dev/null
+++ b/Runtime/Device/State/IBatteryService.cs
@@ -0,0 +1,31 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Wraps Unity's <c>SystemInfo.batteryLevel</c> / <c>SystemInfo.batteryStatus</c> with change events,
+	/// plus iOS / Android low-power-mode awareness.
+	/// </summary>
+	public interface IBatteryService
+	{
+		/// <summary>Current battery charge in <c>[0, 1]</c>; <c>-1</c> if unknown.</summary>
+		float Level { get; }
+
+		/// <summary>Current charging status (<c>Charging</c>, <c>Discharging</c>, <c>NotCharging</c>, <c>Full</c>, <c>Unknown</c>).</summary>
+		BatteryStatus Status { get; }
+
+		/// <summary>True when the OS reports its low-power / battery-saver mode is active.</summary>
+		bool IsLowPowerMode { get; }
+
+		/// <summary>Fired when <see cref="Level"/> changes by more than ~1%.</summary>
+		event Action OnLevelChanged;
+
+		/// <summary>Fired when <see cref="Status"/> transitions.</summary>
+		event Action OnStatusChanged;
+
+		/// <summary>Fired when <see cref="IsLowPowerMode"/> transitions.</summary>
+		event Action OnLowPowerModeChanged;
+	}
+}
diff --git a/Runtime/Device/State/IBatteryService.cs.meta b/Runtime/Device/State/IBatteryService.cs.meta
new file mode 100644
index 0000000..cb1f34f
--- /dev/null
+++ b/Runtime/Device/State/IBatteryService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5ea9e7ecb85e643fdb526cb7e421b78a
\ No newline at end of file
diff --git a/Runtime/Device/State/ISafeAreaService.cs b/Runtime/Device/State/ISafeAreaService.cs
new file mode 100644
index 0000000..ee726f2
--- /dev/null
+++ b/Runtime/Device/State/ISafeAreaService.cs
@@ -0,0 +1,19 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Wraps <c>Screen.safeArea</c> with change events for orientation, notch / dynamic-island
+	/// reveal, and any other runtime safe-area shifts. Fires on diff only.
+	/// </summary>
+	public interface ISafeAreaService
+	{
+		/// <summary>Latest known safe area in screen pixels (cached <c>Screen.safeArea</c>).</summary>
+		Rect SafeArea { get; }
+
+		/// <summary>Fired when the safe area changes (orientation, notch reveal, etc.).</summary>
+		event Action<Rect> OnSafeAreaChanged;
+	}
+}
diff --git a/Runtime/Device/State/ISafeAreaService.cs.meta b/Runtime/Device/State/ISafeAreaService.cs.meta
new file mode 100644
index 0000000..133933e
--- /dev/null
+++ b/Runtime/Device/State/ISafeAreaService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 322efff88ac064790a260aaaea5a4501
\ No newline at end of file
diff --git a/Runtime/Device/State/IScreenWakeService.cs b/Runtime/Device/State/IScreenWakeService.cs
new file mode 100644
index 0000000..d5429c6
--- /dev/null
+++ b/Runtime/Device/State/IScreenWakeService.cs
@@ -0,0 +1,15 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// Controls whether the device screen should stay awake (override the OS sleep timeout).
+	/// </summary>
+	public interface IScreenWakeService
+	{
+		/// <summary>
+		/// When <c>true</c>, sets <c>Screen.sleepTimeout</c> to <c>SleepTimeout.NeverSleep</c>;
+		/// when <c>false</c>, restores <c>SleepTimeout.SystemSetting</c>. Idempotent.
+		/// </summary>
+		bool KeepAwake { get; set; }
+	}
+}
diff --git a/Runtime/Device/State/IScreenWakeService.cs.meta b/Runtime/Device/State/IScreenWakeService.cs.meta
new file mode 100644
index 0000000..08650cf
--- /dev/null
+++ b/Runtime/Device/State/IScreenWakeService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bcda1d4b4808e4808ba9b6fd7e980d99
\ No newline at end of file
diff --git a/Runtime/Device/State/SafeAreaContainer.cs b/Runtime/Device/State/SafeAreaContainer.cs
new file mode 100644
index 0000000..54780b9
--- /dev/null
+++ b/Runtime/Device/State/SafeAreaContainer.cs
@@ -0,0 +1,75 @@
+using UnityEngine;
+using UnityEngine.UIElements;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>
+	/// UI Toolkit container that automatically pads its content to respect the device safe area.
+	/// Subscribe via the constructor that accepts an <see cref="ISafeAreaService"/>; the container
+	/// updates its own padding whenever the safe area changes.
+	/// </summary>
+	[UxmlElement]
+	public sealed partial class SafeAreaContainer : VisualElement
+	{
+		private ISafeAreaService _safeAreaService;
+
+		/// <summary>Default constructor for UXML usage. Set the service via <see cref="SetSafeAreaService"/>.</summary>
+		public SafeAreaContainer()
+		{
+			RegisterCallback<GeometryChangedEvent>(_ => Apply());
+		}
+
+		/// <summary>Code-construction with the service injected.</summary>
+		public SafeAreaContainer(ISafeAreaService safeAreaService) : this()
+		{
+			SetSafeAreaService(safeAreaService);
+		}
+
+		/// <summary>Wires the container to the supplied service and applies the current safe area immediately.</summary>
+		public void SetSafeAreaService(ISafeAreaService safeAreaService)
+		{
+			if (_safeAreaService != null)
+			{
+				_safeAreaService.OnSafeAreaChanged -= OnSafeAreaChanged;
+			}
+
+			_safeAreaService = safeAreaService;
+
+			if (_safeAreaService == null)
+			{
+				return;
+			}
+
+			_safeAreaService.OnSafeAreaChanged += OnSafeAreaChanged;
+			Apply();
+		}
+
+		private void OnSafeAreaChanged(Rect _)
+		{
+			Apply();
+		}
+
+		private void Apply()
+		{
+			var safeArea = _safeAreaService?.SafeArea ?? Screen.safeArea;
+			var screenWidth = Screen.width;
+			var screenHeight = Screen.height;
+			if (screenWidth <= 0 || screenHeight <= 0)
+			{
+				return;
+			}
+
+			// Convert from screen pixels to UI Toolkit padding (top-left origin).
+			var left   = safeArea.xMin;
+			var right  = screenWidth  - safeArea.xMax;
+			var top    = screenHeight - safeArea.yMax;
+			var bottom = safeArea.yMin;
+
+			style.paddingLeft   = left;
+			style.paddingRight  = right;
+			style.paddingTop    = top;
+			style.paddingBottom = bottom;
+		}
+	}
+}
diff --git a/Runtime/Device/State/SafeAreaContainer.cs.meta b/Runtime/Device/State/SafeAreaContainer.cs.meta
new file mode 100644
index 0000000..060ef75
--- /dev/null
+++ b/Runtime/Device/State/SafeAreaContainer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e6962b99fd39d479fbd1d52b08f5b7ed
\ No newline at end of file
diff --git a/Runtime/Device/State/SafeAreaService.cs b/Runtime/Device/State/SafeAreaService.cs
new file mode 100644
index 0000000..5a2be7f
--- /dev/null
+++ b/Runtime/Device/State/SafeAreaService.cs
@@ -0,0 +1,80 @@
+using System;
+using GameLovers.MobileServices.Device.Internal;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class SafeAreaService : ISafeAreaService, IDisposable
+	{
+		private readonly DeviceServicesHost _host;
+
+		private Rect _lastSafeArea;
+		private Vector2Int _lastResolution;
+
+#if UNITY_EDITOR
+		// Editor-only simulator override. When set, the LateUpdate poll reports this rect
+		// instead of Screen.safeArea so the Device Simulator panel can drive notch/dynamic-island previews.
+		internal static Rect? EditorSafeAreaOverride;
+#endif
+
+		/// <inheritdoc />
+		public Rect SafeArea => _lastSafeArea;
+
+		/// <inheritdoc />
+		public event Action<Rect> OnSafeAreaChanged;
+
+		/// <summary>Default ctor uses the package-wide singleton host (<see cref="DeviceServicesHost.Instance"/>).</summary>
+		public SafeAreaService() : this(DeviceServicesHost.Instance) { }
+
+		/// <summary>
+		/// Test/DI overload that accepts an explicit host. Used by <see cref="DeviceService"/> to
+		/// share a single host instance across the umbrella's children, and by tests that want
+		/// deterministic host lifetime.
+		/// </summary>
+		internal SafeAreaService(DeviceServicesHost host)
+		{
+			_host = host;
+			_lastSafeArea = Screen.safeArea;
+			_lastResolution = new Vector2Int(Screen.width, Screen.height);
+			_host.RegisterLateUpdate(Tick);
+		}
+
+		public void Dispose()
+		{
+			_host.UnregisterLateUpdate(Tick);
+		}
+
+		private void Tick()
+		{
+#if UNITY_EDITOR
+			var current = EditorSafeAreaOverride ?? Screen.safeArea;
+#else
+			var current = Screen.safeArea;
+#endif
+			var resolution = new Vector2Int(Screen.width, Screen.height);
+
+			if (current == _lastSafeArea && resolution == _lastResolution)
+			{
+				return;
+			}
+
+			_lastSafeArea = current;
+			_lastResolution = resolution;
+			OnSafeAreaChanged?.Invoke(current);
+		}
+
+#if UNITY_EDITOR
+		/// <summary>
+		/// Editor-only simulator hook. Forces an immediate diff against
+		/// <see cref="EditorSafeAreaOverride"/> so the Device Simulator panel's notch-inset affordance
+		/// surfaces the change without waiting for the next LateUpdate tick.
+		/// </summary>
+		internal void SimulateSafeAreaChanged()
+		{
+			Tick();
+		}
+#endif
+	}
+}
diff --git a/Runtime/Device/State/SafeAreaService.cs.meta b/Runtime/Device/State/SafeAreaService.cs.meta
new file mode 100644
index 0000000..832b65f
--- /dev/null
+++ b/Runtime/Device/State/SafeAreaService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 72d96de654ed341f78d86327d9eb3270
\ No newline at end of file
diff --git a/Runtime/Device/State/ScreenWakeService.cs b/Runtime/Device/State/ScreenWakeService.cs
new file mode 100644
index 0000000..e6ed370
--- /dev/null
+++ b/Runtime/Device/State/ScreenWakeService.cs
@@ -0,0 +1,16 @@
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class ScreenWakeService : IScreenWakeService
+	{
+		/// <inheritdoc />
+		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
new file mode 100644
index 0000000..88e2b1b
--- /dev/null
+++ b/Runtime/Device/State/ScreenWakeService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f4ef98007e9d54bd2acaddde0c165c80
\ No newline at end of file
diff --git a/Runtime/Device/Tracking.meta b/Runtime/Device/Tracking.meta
new file mode 100644
index 0000000..efafc1c
--- /dev/null
+++ b/Runtime/Device/Tracking.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e0a43e62f98464b69a662cec5d41bafd
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Device/Tracking/AttService.cs b/Runtime/Device/Tracking/AttService.cs
new file mode 100644
index 0000000..fa9557c
--- /dev/null
+++ b/Runtime/Device/Tracking/AttService.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device.Internal;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <inheritdoc />
+	public sealed class AttService : IAttService
+	{
+#if UNITY_IOS && !UNITY_EDITOR
+		[DllImport("__Internal")] private static extern int  _GameLoversAttCurrentStatus();
+		[DllImport("__Internal")] private static extern void _GameLoversAttRequestAuthorization(int requestId, string callbackGameObject, string callbackMethod);
+#endif
+
+#if UNITY_EDITOR
+		// Editor-only override hooks consumed by EditorPlatformSimulator so the Device Simulator can
+		// model the real OS flow in the editor. When set, the editor short-circuit paths consult these
+		// instead of returning the default Authorized; when unset, the default is unchanged and runtime
+		// non-Editor builds carry none of this surface.
+		//   - EditorCurrentStatusOverride / EditorRequestResultOverride: synchronous reads (the simulated persisted decision).
+		//   - EditorRequestAsyncOverride: the first request shows the ATT prompt and the returned Task completes when the
+		//     user answers; takes precedence over the synchronous EditorRequestResultOverride.
+		internal static AttStatus? EditorCurrentStatusOverride;
+		internal static AttStatus? EditorRequestResultOverride;
+		internal static Func<Task<AttStatus>> EditorRequestAsyncOverride;
+#endif
+
+		/// <inheritdoc />
+		public AttStatus CurrentStatus
+		{
+			get
+			{
+#if UNITY_IOS && !UNITY_EDITOR
+				return (AttStatus)_GameLoversAttCurrentStatus();
+#elif UNITY_EDITOR
+				return EditorCurrentStatusOverride ?? AttStatus.Authorized;
+#else
+				return AttStatus.Authorized;
+#endif
+			}
+		}
+
+		/// <inheritdoc />
+		public Task<AttStatus> RequestAuthorizationAsync()
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			var tcs = new TaskCompletionSource<AttStatus>();
+			var id = AttCallbackReceiver.Instance.Register(tcs);
+			_GameLoversAttRequestAuthorization(id, "AttCallbackReceiver", "OnAttResult");
+			return tcs.Task;
+#elif UNITY_EDITOR
+			var asyncOver = EditorRequestAsyncOverride;
+			if (asyncOver != null)
+			{
+				return asyncOver();
+			}
+			return Task.FromResult(EditorRequestResultOverride ?? AttStatus.Authorized);
+#else
+			return Task.FromResult(AttStatus.Authorized);
+#endif
+		}
+	}
+}
+
+namespace GameLovers.MobileServices.Device.Internal
+{
+	/// <summary>
+	/// Internal MonoBehaviour that receives ATT results from the iOS bridge via <c>UnitySendMessage</c>.
+	/// Mirrors the shape of <see cref="PermissionsCallbackReceiver"/> so each subsystem owns its own
+	/// payload format and we don't have to multiplex.
+	/// </summary>
+	internal sealed class AttCallbackReceiver : MonoBehaviour
+	{
+		private static AttCallbackReceiver _instance;
+		private readonly Dictionary<int, TaskCompletionSource<AttStatus>> _pending =
+			new Dictionary<int, TaskCompletionSource<AttStatus>>();
+		private int _nextId = 1;
+
+		public static AttCallbackReceiver Instance
+		{
+			get
+			{
+				if (_instance != null)
+				{
+					return _instance;
+				}
+
+				var go = new GameObject("AttCallbackReceiver");
+				DontDestroyOnLoad(go);
+				_instance = go.AddComponent<AttCallbackReceiver>();
+				return _instance;
+			}
+		}
+
+		public int Register(TaskCompletionSource<AttStatus> tcs)
+		{
+			var id = _nextId++;
+			_pending[id] = tcs;
+			return id;
+		}
+
+		// Native iOS bridge calls UnitySendMessage("AttCallbackReceiver", "OnAttResult", "<id>:<status>")
+		// where status is the int value of AttStatus.
+		// ReSharper disable once UnusedMember.Global
+		// ReSharper disable once InconsistentNaming
+		public void OnAttResult(string payload)
+		{
+			try
+			{
+				var sep = payload.IndexOf(':');
+				if (sep <= 0) return;
+				var idText = payload.Substring(0, sep);
+				var statusText = payload.Substring(sep + 1);
+
+				if (!int.TryParse(idText, out var id) || !int.TryParse(statusText, out var statusInt))
+				{
+					return;
+				}
+
+				if (!_pending.TryGetValue(id, out var tcs))
+				{
+					return;
+				}
+
+				_pending.Remove(id);
+				tcs.TrySetResult((AttStatus)statusInt);
+			}
+			catch (Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] AttCallbackReceiver failed to parse '{payload}': {e.Message}");
+			}
+		}
+
+		private void OnDestroy()
+		{
+			if (_instance == this)
+			{
+				_instance = null;
+			}
+			foreach (var tcs in _pending.Values)
+			{
+				tcs.TrySetCanceled();
+			}
+			_pending.Clear();
+		}
+	}
+}
diff --git a/Runtime/Device/Tracking/AttService.cs.meta b/Runtime/Device/Tracking/AttService.cs.meta
new file mode 100644
index 0000000..c6dd7bf
--- /dev/null
+++ b/Runtime/Device/Tracking/AttService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8bdbb8a0b6ac74dd3989c02d54238c5b
\ No newline at end of file
diff --git a/Runtime/Device/Tracking/IAttService.cs b/Runtime/Device/Tracking/IAttService.cs
new file mode 100644
index 0000000..8ed2672
--- /dev/null
+++ b/Runtime/Device/Tracking/IAttService.cs
@@ -0,0 +1,34 @@
+using System.Threading.Tasks;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Device
+{
+	/// <summary>App Tracking Transparency authorization status (mirrors iOS <c>ATTrackingManagerAuthorizationStatus</c>).</summary>
+	public enum AttStatus
+	{
+		NotDetermined = 0,
+		Restricted    = 1,
+		Denied        = 2,
+		Authorized    = 3,
+	}
+
+	/// <summary>
+	/// iOS 14.5+ App Tracking Transparency. Android / Editor / unsupported platforms always return
+	/// <see cref="AttStatus.Authorized"/> (no equivalent restriction).
+	/// </summary>
+	/// <remarks>
+	/// Built directly on <c>ATTrackingManager</c> with no dependency on the deprecation-bound
+	/// <c>com.unity.ads.ios-support</c> package.
+	/// </remarks>
+	public interface IAttService
+	{
+		/// <summary>Current authorization status without prompting.</summary>
+		AttStatus CurrentStatus { get; }
+
+		/// <summary>
+		/// Requests tracking authorization. Idempotent: if the user has already responded (granted,
+		/// denied, or restricted) the OS returns the previous decision without showing the prompt again.
+		/// </summary>
+		Task<AttStatus> RequestAuthorizationAsync();
+	}
+}
diff --git a/Runtime/Device/Tracking/IAttService.cs.meta b/Runtime/Device/Tracking/IAttService.cs.meta
new file mode 100644
index 0000000..d44a8b8
--- /dev/null
+++ b/Runtime/Device/Tracking/IAttService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0e5f5ad2ead7f40069765b00f21f19cf
\ No newline at end of file
diff --git a/Runtime/GameLovers.NativeUi.asmdef b/Runtime/GameLovers.MobileServices.asmdef
similarity index 51%
rename from Runtime/GameLovers.NativeUi.asmdef
rename to Runtime/GameLovers.MobileServices.asmdef
index 2902d16..e100fb3 100644
--- a/Runtime/GameLovers.NativeUi.asmdef
+++ b/Runtime/GameLovers.MobileServices.asmdef
@@ -1,6 +1,12 @@
 {
-    "name": "GameLovers.NativeUi",
-    "references": [],
+    "name": "GameLovers.MobileServices",
+    "rootNamespace": "GameLovers.MobileServices",
+    "references": [
+        "Unity.Notifications",
+        "Unity.Notifications.Android",
+        "Unity.Notifications.iOS",
+        "Unity.InputSystem"
+    ],
     "includePlatforms": [],
     "excludePlatforms": [],
     "allowUnsafeCode": false,
@@ -10,4 +16,4 @@
     "defineConstraints": [],
     "versionDefines": [],
     "noEngineReferences": false
-}
\ No newline at end of file
+}
diff --git a/Runtime/GameLovers.MobileServices.asmdef.meta b/Runtime/GameLovers.MobileServices.asmdef.meta
new file mode 100644
index 0000000..03869c0
--- /dev/null
+++ b/Runtime/GameLovers.MobileServices.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: cbe822915a7aa48eba069762603eb81d
+AssemblyDefinitionImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Gestures.meta b/Runtime/Gestures.meta
new file mode 100644
index 0000000..cc9dc80
--- /dev/null
+++ b/Runtime/Gestures.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5f1c420d688824f7eb6729b2f1a4d316
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Gestures/ActiveGesture.cs b/Runtime/Gestures/ActiveGesture.cs
new file mode 100755
index 0000000..59c5ef9
--- /dev/null
+++ b/Runtime/Gestures/ActiveGesture.cs
@@ -0,0 +1,118 @@
+using UnityEngine;
+
+namespace GameLovers.MobileServices.Gestures
+{
+    /// <summary>
+    /// An in-progress potential gesture for given input.
+    /// </summary>
+    internal sealed class ActiveGesture
+    {
+        /// <summary>
+        /// Input ID that generated this gesture.
+        /// </summary>
+        public int InputId;
+
+        /// <summary>
+        /// The time this potential gesture started.
+        /// </summary>
+        public readonly double StartTime;
+
+        /// <summary>
+        /// The time this potential gesture ended.
+        /// </summary>
+        public double EndTime;
+
+        /// <summary>
+        /// The position this gesture started at.
+        /// </summary>
+        public readonly Vector2 StartPosition;
+
+        /// <summary>
+        /// The position this gesture was at during the last sample.
+        /// </summary>
+        public Vector2 PreviousPosition;
+
+        /// <summary>
+        /// The position this gesture ended at.
+        /// </summary>
+        public Vector2 EndPosition;
+
+        /// <summary>
+        /// How many samples we had for this gesture.
+        /// </summary>
+        public int Samples;
+
+        /// <summary>
+        /// How consistent the swipe was in its direction. Approaches 1 for straight lines.
+        /// </summary>
+        /// <remarks>
+        /// This is calculated as the average of the dot products of every line segment (normalized) against a normalized
+        /// vector to the tip of the swipe from the start.
+        /// </remarks>
+        public float SwipeDirectionSameness;
+
+        /// <summary>
+        /// The total travel distance this gesture's made in screen units. This will always be AT LEAST the distance
+        /// between <see cref="StartPosition"/> and <see cref="EndPosition"/>, but will likely be longer for any
+        /// non straight line gestures.
+        /// </summary>
+        public float TravelDistance;
+
+        /// <summary>
+        /// Accumulated sum of all normalized movement vectors.
+        /// </summary>
+        private Vector2 accumulatedNormalized;
+
+        /// <summary>
+        /// Instantiate a new potential gesture.
+        /// </summary>
+        /// <param name="inputId">The input id for this gesture.</param>
+        /// <param name="startPosition">The gesture's start position.</param>
+        /// <param name="startTime">The time the gesture has started.</param>
+        public ActiveGesture(int inputId, Vector2 startPosition, double startTime)
+        {
+            InputId = inputId;
+            EndTime = StartTime = startTime;
+            EndPosition = StartPosition = startPosition;
+            Samples = 1;
+            SwipeDirectionSameness = 1;
+            accumulatedNormalized = Vector2.zero;
+        }
+
+        /// <summary>
+        /// Submit a new position to this gesture.
+        /// </summary>
+        /// <param name="position">The position of the new sample.</param>
+        /// <param name="time">The time of the new sample.</param>
+        public void SubmitPoint(Vector2 position, double time)
+        {
+            Vector2 toNewPosition = position - EndPosition;
+            float distanceMoved = toNewPosition.magnitude;
+
+            // Set new end time
+            EndTime = time;
+
+            if (Mathf.Approximately(distanceMoved, 0))
+            {
+                // Skipping point that is in the same position as the last one
+                return;
+            }
+
+            // Normalize
+            toNewPosition /= distanceMoved;
+
+            Samples++;
+            Vector2 toNewEndPosition = (position - StartPosition).normalized;
+
+            // Set new end position and previous positions
+            PreviousPosition = EndPosition;
+            EndPosition = position;
+
+            accumulatedNormalized += toNewPosition;
+
+            SwipeDirectionSameness = Vector2.Dot(toNewEndPosition, accumulatedNormalized / (Samples - 1));
+
+            TravelDistance += distanceMoved;
+        }
+    }
+}
diff --git a/Runtime/Gestures/ActiveGesture.cs.meta b/Runtime/Gestures/ActiveGesture.cs.meta
new file mode 100644
index 0000000..11627b6
--- /dev/null
+++ b/Runtime/Gestures/ActiveGesture.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1394de70e523f463088df8e2da31f934
\ No newline at end of file
diff --git a/Runtime/Gestures/GestureController.cs b/Runtime/Gestures/GestureController.cs
new file mode 100755
index 0000000..8f867ff
--- /dev/null
+++ b/Runtime/Gestures/GestureController.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.InputSystem.EnhancedTouch;
+using UnityEngine.Serialization;
+using UnityEngine.UI;
+using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
+
+namespace GameLovers.MobileServices.Gestures
+{
+    /// <summary>
+    /// Controller that interprets takes pointer input from <see cref="Touch"/> and detects
+    /// directional swipes and detects taps.
+    /// </summary>
+    public class GestureController : MonoBehaviour
+    {
+        // Maximum duration of a press before it can no longer be considered a tap.
+        [SerializeField]
+        private float _maxTapDuration = 0.2f;
+
+        // Maximum distance in screen units that a tap can drift from its original position before
+        // it is no longer considered a tap.
+        [SerializeField]
+        private float _maxTapDrift = 5.0f;
+
+        // Maximum duration of a swipe before it is no longer considered to be a valid swipe.
+        [SerializeField]
+        private float _maxSwipeDuration = 0.5f;
+
+        // Minimum distance in screen units that a swipe must move before it is considered a swipe.
+        // Note that if this is smaller or equal to maxTapDrift, then it is possible for a user action to be
+        // returned as both a swipe and a tap.
+        [SerializeField]
+        private float _minSwipeDistance = 10.0f;
+
+        // How much a swipe should consistently be in the same direction before it is considered a swipe.
+        [SerializeField]
+        private float _swipeDirectionSamenessThreshold = 0.6f;
+
+        [FormerlySerializedAs("label")]
+        [Header("Debug"), SerializeField]
+        private Text _label;
+
+        // Mapping of input IDs to their active gesture tracking objects.
+        private readonly Dictionary<int, ActiveGesture> _activeGestures = new Dictionary<int, ActiveGesture>();
+
+        /// <summary>
+        /// Event fired when the user presses on the screen.
+        /// </summary>
+        public event Action<SwipeInput> Pressed;
+
+        /// <summary>
+        /// Event fired for every motion (possibly multiple times a frame) of a potential swipe gesture.
+        /// </summary>
+        public event Action<SwipeInput> PotentiallySwiped;
+
+        /// <summary>
+        /// Event fired when a user performs a swipe gesture.
+        /// </summary>
+        public event Action<SwipeInput> Swiped;
+
+        /// <summary>
+        /// Event fired when a user performs a tap gesture, on releasing.
+        /// </summary>
+        public event Action<TapInput> Tapped;
+
+        protected virtual void OnEnable()
+        {
+            EnhancedTouchSupport.Enable();
+            Touch.onFingerDown += OnFingerDown;
+            Touch.onFingerMove += OnFingerMove;
+            Touch.onFingerUp += OnFingerUp;
+        }
+
+        protected virtual void OnDisable()
+        {
+            Touch.onFingerDown -= OnFingerDown;
+            Touch.onFingerMove -= OnFingerMove;
+            Touch.onFingerUp -= OnFingerUp;
+            EnhancedTouchSupport.Disable();
+        }
+
+        private void OnFingerDown(Finger finger)
+        {
+            var touch = finger.currentTouch;
+            OnPressed(finger.index, touch.screenPosition, touch.time);
+        }
+
+        private void OnFingerMove(Finger finger)
+        {
+            var touch = finger.currentTouch;
+            OnDragged(finger.index, touch.screenPosition, touch.time);
+        }
+
+        private void OnFingerUp(Finger finger)
+        {
+            var touch = finger.currentTouch;
+            OnReleased(finger.index, touch.screenPosition, touch.time);
+        }
+
+        /// <summary>
+        /// Checks whether a given active gesture will be a valid swipe.
+        /// </summary>
+        private bool IsValidSwipe(ref ActiveGesture gesture)
+        {
+            return gesture.TravelDistance >= _minSwipeDistance &&
+                (gesture.EndTime - gesture.StartTime) <= _maxSwipeDuration &&
+                gesture.SwipeDirectionSameness >= _swipeDirectionSamenessThreshold;
+        }
+
+        /// <summary>
+        /// Checks whether a given active gesture will be a valid tap.
+        /// </summary>
+        private bool IsValidTap(ref ActiveGesture gesture)
+        {
+            return gesture.TravelDistance <= _maxTapDrift &&
+                (gesture.EndTime - gesture.StartTime) <= _maxTapDuration;
+        }
+
+        private void OnPressed(int inputId, Vector2 position, double time)
+        {
+            Debug.Assert(!_activeGestures.ContainsKey(inputId));
+
+            var newGesture = new ActiveGesture(inputId, position, time);
+            _activeGestures.Add(inputId, newGesture);
+
+            DebugInfo(newGesture);
+
+            Pressed?.Invoke(new SwipeInput(newGesture));
+        }
+
+        private void OnDragged(int inputId, Vector2 position, double time)
+        {
+            if (!_activeGestures.TryGetValue(inputId, out var existingGesture))
+            {
+                // Probably caught by UI, or the input was otherwise lost
+                return;
+            }
+
+            existingGesture.SubmitPoint(position, time);
+
+            if (IsValidSwipe(ref existingGesture))
+            {
+                PotentiallySwiped?.Invoke(new SwipeInput(existingGesture));
+            }
+
+            DebugInfo(existingGesture);
+        }
+
+        private void OnReleased(int inputId, Vector2 position, double time)
+        {
+            if (!_activeGestures.TryGetValue(inputId, out var existingGesture))
+            {
+                // Probably caught by UI, or the input was otherwise lost
+                return;
+            }
+
+            _activeGestures.Remove(inputId);
+            existingGesture.SubmitPoint(position, time);
+
+            if (IsValidSwipe(ref existingGesture))
+            {
+                Swiped?.Invoke(new SwipeInput(existingGesture));
+            }
+
+            if (IsValidTap(ref existingGesture))
+            {
+                Tapped?.Invoke(new TapInput(existingGesture));
+            }
+
+            DebugInfo(existingGesture);
+        }
+
+        private void DebugInfo(ActiveGesture gesture)
+        {
+            if (_label == null) return;
+
+            var builder = new StringBuilder();
+
+            builder.AppendFormat("ID: {0}", gesture.InputId);
+            builder.AppendLine();
+            builder.AppendFormat("Start Position: {0}", gesture.StartPosition);
+            builder.AppendLine();
+            builder.AppendFormat("Position: {0}", gesture.EndPosition);
+            builder.AppendLine();
+            builder.AppendFormat("Duration: {0}", gesture.EndTime - gesture.StartTime);
+            builder.AppendLine();
+            builder.AppendFormat("Sameness: {0}", gesture.SwipeDirectionSameness);
+            builder.AppendLine();
+            builder.AppendFormat("Travel distance: {0}", gesture.TravelDistance);
+            builder.AppendLine();
+            builder.AppendFormat("Samples: {0}", gesture.Samples);
+            builder.AppendLine();
+            builder.AppendFormat("Realtime since startup: {0}", Time.realtimeSinceStartup);
+            builder.AppendLine();
+            builder.AppendFormat("Starting Timestamp: {0}", gesture.StartTime);
+            builder.AppendLine();
+            builder.AppendFormat("Ending Timestamp: {0}", gesture.EndTime);
+            builder.AppendLine();
+
+            _label.text = builder.ToString();
+
+            if (Camera.main == null) return;
+
+            var worldStart = Camera.main.ScreenToWorldPoint(gesture.StartPosition);
+            var worldEnd = Camera.main.ScreenToWorldPoint(gesture.EndPosition);
+
+            worldStart.z += 5;
+            worldEnd.z += 5;
+        }
+    }
+}
diff --git a/Runtime/Gestures/GestureController.cs.meta b/Runtime/Gestures/GestureController.cs.meta
new file mode 100644
index 0000000..8ff6f36
--- /dev/null
+++ b/Runtime/Gestures/GestureController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0a52942e227ea44719cc286762db5d23
\ No newline at end of file
diff --git a/Runtime/Gestures/SwipeInput.cs b/Runtime/Gestures/SwipeInput.cs
new file mode 100755
index 0000000..ba7a056
--- /dev/null
+++ b/Runtime/Gestures/SwipeInput.cs
@@ -0,0 +1,77 @@
+using UnityEngine;
+
+namespace GameLovers.MobileServices.Gestures
+{
+    /// <summary>
+    /// Simple object to contain information for a swipe input.
+    /// </summary>
+    public struct SwipeInput
+    {
+        /// <summary>
+        /// ID of input that performed this swipe.
+        /// </summary>
+        public readonly int InputId;
+
+        /// <summary>
+        /// Position that the swipe began.
+        /// </summary>
+        public readonly Vector2 StartPosition;
+
+        /// <summary>
+        /// Last position that this swipe was at.
+        /// </summary>
+        public readonly Vector2 PreviousPosition;
+
+        /// <summary>
+        /// End position of the swipe.
+        /// </summary>
+        public readonly Vector2 EndPosition;
+
+        /// <summary>
+        /// Average normalized direction of the swipe. This is equivalent to
+        /// <c>(EndPosition - StartPosition).normalized</c>.
+        /// </summary>
+        public readonly Vector2 SwipeDirection;
+
+        /// <summary>
+        /// Average velocity of the swipe in screen units per second.
+        /// </summary>
+        public readonly float SwipeVelocity;
+
+        /// <summary>
+        /// How much the swipe travelled in screen units. Will always be at least the difference between
+        /// <see cref="StartPosition"/> and <see cref="EndPosition"/>, but will be longer for non-straight lines.
+        /// </summary>
+        public readonly float TravelDistance;
+
+        /// <summary>
+        /// Duration of the swipe in seconds.
+        /// </summary>
+        public readonly double SwipeDuration;
+
+        /// <summary>
+        /// A normalized measure of how consistent this swipe was in direction.
+        /// </summary>
+        public readonly float SwipeSameness;
+
+        /// <summary>
+        /// Construct a new swipe input from a given gesture.
+        /// </summary>
+        internal SwipeInput(ActiveGesture gesture) : this()
+        {
+            InputId = gesture.InputId;
+            StartPosition = gesture.StartPosition;
+            PreviousPosition = gesture.PreviousPosition;
+            EndPosition = gesture.EndPosition;
+            SwipeDirection = (EndPosition - StartPosition).normalized;
+            SwipeDuration = gesture.EndTime - gesture.StartTime;
+            TravelDistance = gesture.TravelDistance;
+            SwipeSameness = gesture.SwipeDirectionSameness;
+
+            if (SwipeDuration > 0.0f)
+            {
+                SwipeVelocity = (float)(TravelDistance / SwipeDuration);
+            }
+        }
+    }
+}
diff --git a/Runtime/Gestures/SwipeInput.cs.meta b/Runtime/Gestures/SwipeInput.cs.meta
new file mode 100644
index 0000000..2d1069f
--- /dev/null
+++ b/Runtime/Gestures/SwipeInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5b454d6ecc4764d5dad9b4839a1f24ad
\ No newline at end of file
diff --git a/Runtime/Gestures/TapInput.cs b/Runtime/Gestures/TapInput.cs
new file mode 100644
index 0000000..4c5ae05
--- /dev/null
+++ b/Runtime/Gestures/TapInput.cs
@@ -0,0 +1,22 @@
+using UnityEngine;
+
+namespace GameLovers.MobileServices.Gestures
+{
+    public struct TapInput
+    {
+        public readonly Vector2 PressPosition;
+        public readonly Vector2 ReleasePosition;
+        public readonly double TapDuration;
+        public readonly float TapDrift;
+        public readonly double TimeStamp;
+
+        internal TapInput(ActiveGesture gesture) : this()
+        {
+            PressPosition = gesture.StartPosition;
+            ReleasePosition = gesture.EndPosition;
+            TapDuration = gesture.EndTime - gesture.StartTime;
+            TapDrift = gesture.TravelDistance;
+            TimeStamp = gesture.EndTime;
+        }
+    }
+}
diff --git a/Runtime/Gestures/TapInput.cs.meta b/Runtime/Gestures/TapInput.cs.meta
new file mode 100644
index 0000000..8dfc93d
--- /dev/null
+++ b/Runtime/Gestures/TapInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2df17263f80ff4e4c9c3ad6f8a0ca32c
\ No newline at end of file
diff --git a/Runtime/Haptics.meta b/Runtime/Haptics.meta
new file mode 100644
index 0000000..eb0d3d1
--- /dev/null
+++ b/Runtime/Haptics.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: de2f36c03a9d942e597b675c04c65cd1
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Haptics/HapticPreset.cs b/Runtime/Haptics/HapticPreset.cs
new file mode 100644
index 0000000..bd2abe6
--- /dev/null
+++ b/Runtime/Haptics/HapticPreset.cs
@@ -0,0 +1,40 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics
+{
+	/// <summary>
+	/// Catalogue of cross-platform haptic feedback presets.
+	/// Each preset maps to platform-native primitives (iOS UIFeedbackGenerator family / Android VibrationEffect waveform).
+	/// </summary>
+	public enum HapticPreset
+	{
+		/// <summary>No haptic. Calls are short-circuited.</summary>
+		None = 0,
+
+		/// <summary>Crisp tick suitable for picker / discrete value changes.</summary>
+		Selection = 1,
+
+		/// <summary>Two-tap success notification (ascending).</summary>
+		Success = 2,
+
+		/// <summary>Single warning notification.</summary>
+		Warning = 3,
+
+		/// <summary>Multi-tap error notification.</summary>
+		Error = 4,
+
+		/// <summary>Soft, low-amplitude impact.</summary>
+		ImpactLight = 5,
+
+		/// <summary>Default impact strength.</summary>
+		ImpactMedium = 6,
+
+		/// <summary>Strong impact for major hits.</summary>
+		ImpactHeavy = 7,
+
+		/// <summary>Sharp, short impact (snappy).</summary>
+		ImpactRigid = 8,
+
+		/// <summary>Gentle, longer impact (cushioned).</summary>
+		ImpactSoft = 9,
+	}
+}
diff --git a/Runtime/Haptics/HapticPreset.cs.meta b/Runtime/Haptics/HapticPreset.cs.meta
new file mode 100644
index 0000000..7898c5e
--- /dev/null
+++ b/Runtime/Haptics/HapticPreset.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 105f8a400aa0b4d60bed6e7f2919c921
\ No newline at end of file
diff --git a/Runtime/Haptics/HapticsService.cs b/Runtime/Haptics/HapticsService.cs
new file mode 100644
index 0000000..9f509f6
--- /dev/null
+++ b/Runtime/Haptics/HapticsService.cs
@@ -0,0 +1,187 @@
+using GameLovers.MobileServices.Haptics.Internal;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics
+{
+	/// <inheritdoc />
+	public sealed class HapticsService : IHapticsService
+	{
+		private readonly IHapticsBackend _backend;
+
+		// HapticsHost MonoBehaviour is spawned lazily on the first Play* call so this service can
+		// safely be constructed during DI bootstrap (before any GameObject scenes exist).
+		private HapticsHost _host;
+
+		private bool _enabled = true;
+		private bool _isPlaying;
+		private HapticPreset _currentPreset;
+		private float _currentDurationSeconds;
+
+		public HapticsService() : this(CreateDefaultBackend()) { }
+
+		internal HapticsService(IHapticsBackend backend)
+		{
+			_backend = backend;
+		}
+
+		/// <summary>
+		/// The preset most recently passed to a <c>Play*</c> call. Reads <see cref="HapticPreset.None"/>
+		/// when no haptic is currently playing or when the last call was <see cref="PlayCustom"/>.
+		/// </summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal HapticPreset CurrentPreset => _isPlaying ? _currentPreset : HapticPreset.None;
+
+		/// <summary>
+		/// Real-time-seconds duration scheduled for the active haptic. <c>0</c> when nothing is playing,
+		/// the preset's natural one-shot duration when invoked via <see cref="PlayPreset"/>,
+		/// <c>-1</c> for an indefinite loop, or the explicit positive duration passed to
+		/// <see cref="PlayPresetDuration"/> / <see cref="PlayCustom"/>.
+		/// </summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal float CurrentDurationSeconds => _isPlaying ? _currentDurationSeconds : 0f;
+
+		/// <summary>The backend selected for the current platform.</summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal IHapticsBackend Backend => _backend;
+
+		/// <inheritdoc />
+		public bool Enabled
+		{
+			get => _enabled;
+			set
+			{
+				if (_enabled == value)
+				{
+					return;
+				}
+				_enabled = value;
+				if (!_enabled)
+				{
+					StopCurrentHaptic();
+				}
+			}
+		}
+
+		/// <inheritdoc />
+		public bool IsSupported => _backend.IsSupported;
+
+		/// <inheritdoc />
+		public bool IsPlaying => _isPlaying;
+
+		/// <inheritdoc />
+		public void PlayPreset(HapticPreset preset)
+		{
+			PlayPresetDuration(preset, 0f);
+		}
+
+		/// <inheritdoc />
+		public void PlayPresetDuration(HapticPreset preset, float duration = -1f)
+		{
+			if (!_enabled || preset == HapticPreset.None)
+			{
+				return;
+			}
+
+			CancelPendingAutoStop();
+
+			if (duration == 0f)
+			{
+				_backend.PlayPresetOneShot(preset);
+				_isPlaying = true;
+				_currentPreset = preset;
+				_currentDurationSeconds = HapticEnvelopes.GetNaturalDurationSeconds(preset);
+				return;
+			}
+
+			_backend.PlayPresetLoop(preset);
+			_isPlaying = true;
+			_currentPreset = preset;
+			_currentDurationSeconds = duration;
+
+			if (duration > 0f)
+			{
+				EnsureHost().ScheduleStop(duration, OnAutoStop);
+			}
+		}
+
+		/// <inheritdoc />
+		public void PlayCustom(float intensity01, float durationMs)
+		{
+			if (!_enabled || durationMs <= 0f)
+			{
+				return;
+			}
+
+			CancelPendingAutoStop();
+
+			intensity01 = Mathf.Clamp01(intensity01);
+			_backend.PlayCustom(intensity01, durationMs);
+			_isPlaying = true;
+			_currentPreset = HapticPreset.None;
+			_currentDurationSeconds = durationMs / 1000f;
+
+			EnsureHost().ScheduleStop(durationMs / 1000f, OnAutoStop);
+		}
+
+		/// <inheritdoc />
+		public void StopCurrentHaptic()
+		{
+			CancelPendingAutoStop();
+			if (!_isPlaying)
+			{
+				return;
+			}
+			_backend.Stop();
+			_isPlaying = false;
+			_currentPreset = HapticPreset.None;
+			_currentDurationSeconds = 0f;
+		}
+
+		private void OnAutoStop()
+		{
+			if (!_isPlaying)
+			{
+				return;
+			}
+			_backend.Stop();
+			_isPlaying = false;
+			_currentPreset = HapticPreset.None;
+			_currentDurationSeconds = 0f;
+		}
+
+		private void CancelPendingAutoStop()
+		{
+			if (_host != null)
+			{
+				_host.Cancel();
+			}
+		}
+
+		private HapticsHost EnsureHost()
+		{
+			if (_host != null)
+			{
+				return _host;
+			}
+
+			var go = new GameObject("HapticsHost");
+			Object.DontDestroyOnLoad(go);
+			_host = go.AddComponent<HapticsHost>();
+			return _host;
+		}
+
+		private static IHapticsBackend CreateDefaultBackend()
+		{
+#if UNITY_EDITOR
+			return new EditorHapticsBackend();
+#elif UNITY_IOS
+			return new IosHapticsBackend();
+#elif UNITY_ANDROID
+			return new AndroidHapticsBackend();
+#else
+			return new NoOpHapticsBackend();
+#endif
+		}
+	}
+}
diff --git a/Runtime/Haptics/HapticsService.cs.meta b/Runtime/Haptics/HapticsService.cs.meta
new file mode 100644
index 0000000..d3a5a51
--- /dev/null
+++ b/Runtime/Haptics/HapticsService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fcee7f80153054a108da13c0e904968f
\ No newline at end of file
diff --git a/Runtime/Haptics/IHapticsService.cs b/Runtime/Haptics/IHapticsService.cs
new file mode 100644
index 0000000..19b9026
--- /dev/null
+++ b/Runtime/Haptics/IHapticsService.cs
@@ -0,0 +1,60 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics
+{
+	/// <summary>
+	/// Cross-platform haptic feedback. Built directly on iOS <c>UI*FeedbackGenerator</c> (iOS 10+) and
+	/// Android <c>VibrationEffect.createWaveform</c> (API 26+) — no third-party plugin required.
+	/// On Editor / unsupported platforms every call is a safe no-op.
+	/// </summary>
+	public interface IHapticsService
+	{
+		/// <summary>
+		/// Master toggle. When <c>false</c>, every <c>Play*</c> call returns immediately without
+		/// touching native. Setting this to <c>false</c> while a haptic is active also calls
+		/// <see cref="StopCurrentHaptic"/> internally.
+		/// </summary>
+		bool Enabled { get; set; }
+
+		/// <summary>
+		/// True when the device can do at least basic vibration (Android: <c>SystemInfo.supportsVibration</c>;
+		/// iOS: device family supports <c>UIFeedbackGenerator</c>; otherwise false).
+		/// </summary>
+		bool IsSupported { get; }
+
+		/// <summary>
+		/// True between any <c>Play*</c> call and the matching stop (auto or manual).
+		/// </summary>
+		bool IsPlaying { get; }
+
+		/// <summary>
+		/// Plays a one-shot preset using its natural duration. Convenience for
+		/// <see cref="PlayPresetDuration"/> with <c>duration = 0f</c>.
+		/// </summary>
+		void PlayPreset(HapticPreset preset);
+
+		/// <summary>
+		/// Plays a preset with explicit duration semantics:
+		/// <list type="bullet">
+		/// <item><c>duration == 0f</c> — play the preset's natural one-shot duration.</item>
+		/// <item><c>duration &lt; 0f</c> (default <c>-1f</c>) — loop indefinitely. Caller MUST
+		/// invoke <see cref="StopCurrentHaptic"/> to end it.</item>
+		/// <item><c>duration &gt; 0f</c> — loop the preset and auto-stop after <paramref name="duration"/>
+		/// real-time seconds (unaffected by <c>Time.timeScale</c>).</item>
+		/// </list>
+		/// </summary>
+		void PlayPresetDuration(HapticPreset preset, float duration = -1f);
+
+		/// <summary>
+		/// Plays a single custom-intensity haptic and auto-stops after <paramref name="durationMs"/>.
+		/// <paramref name="intensity01"/> is clamped to <c>[0, 1]</c>.
+		/// </summary>
+		void PlayCustom(float intensity01, float durationMs);
+
+		/// <summary>
+		/// Stops any active haptic immediately, regardless of which <c>Play*</c> started it.
+		/// Safe to call when nothing is playing (no-op). Also cancels any pending auto-stop scheduled
+		/// by <see cref="PlayPresetDuration"/> with <c>duration &gt; 0f</c> or by <see cref="PlayCustom"/>.
+		/// </summary>
+		void StopCurrentHaptic();
+	}
+}
diff --git a/Runtime/Haptics/IHapticsService.cs.meta b/Runtime/Haptics/IHapticsService.cs.meta
new file mode 100644
index 0000000..54b493d
--- /dev/null
+++ b/Runtime/Haptics/IHapticsService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3817f6dea02794512a17b6aba1e104ec
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal.meta b/Runtime/Haptics/Internal.meta
new file mode 100644
index 0000000..c4b29f0
--- /dev/null
+++ b/Runtime/Haptics/Internal.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e6acccd3e4a2a4ead9dd10ef0458fcf5
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs
new file mode 100644
index 0000000..33c916e
--- /dev/null
+++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs
@@ -0,0 +1,154 @@
+#if UNITY_ANDROID && !UNITY_EDITOR
+using UnityEngine;
+#endif
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Android implementation built on <c>android.os.Vibrator.vibrate(VibrationEffect)</c> via JNI.
+	/// Uses <c>VibrationEffect.createWaveform(long[] timings, int[] amplitudes, int repeat)</c> for
+	/// preset playback. Preset envelopes were translated from the Lofelt time/amplitude pairs
+	/// used by the demons reference; every line of code here is original.
+	/// Requires API level 26 (Android 8.0) or higher.
+	/// </summary>
+	internal sealed class AndroidHapticsBackend : IHapticsBackend
+	{
+#if UNITY_ANDROID && !UNITY_EDITOR
+		private const int RepeatLoop  = 0;
+		private const int RepeatNone  = -1;
+		private const int DefaultAmplitude = -1; // VibrationEffect.DEFAULT_AMPLITUDE
+
+		private AndroidJavaObject _vibrator;
+		private AndroidJavaClass  _vibrationEffectClass;
+		private bool _initialized;
+#endif
+
+		/// <inheritdoc />
+		public bool IsSupported
+		{
+			get
+			{
+#if UNITY_ANDROID && !UNITY_EDITOR
+				return SystemInfo.supportsVibration;
+#else
+				return false;
+#endif
+			}
+		}
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+		private bool EnsureInitialized()
+		{
+			if (_initialized)
+			{
+				return _vibrator != null;
+			}
+
+			_initialized = true;
+
+			try
+			{
+				using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
+				using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
+				{
+					_vibrator = activity.Call<AndroidJavaObject>("getSystemService", "vibrator");
+				}
+
+				_vibrationEffectClass = new AndroidJavaClass("android.os.VibrationEffect");
+			}
+			catch (System.Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Haptics init failed: {e.Message}");
+				_vibrator = null;
+				_vibrationEffectClass = null;
+			}
+
+			return _vibrator != null && _vibrationEffectClass != null;
+		}
+
+		private void PlayWaveform(HapticPreset preset, int repeatIndex)
+		{
+			if (!EnsureInitialized() || preset == HapticPreset.None)
+			{
+				return;
+			}
+
+			// Envelope tables live in HapticEnvelopes so the editor explorer can reuse the
+			// exact same (timings, amplitudes) the device receives.
+			var (timingsMs, amplitudes) = HapticEnvelopes.GetEnvelopeFor(preset);
+			try
+			{
+				using var effect = _vibrationEffectClass.CallStatic<AndroidJavaObject>(
+					"createWaveform", timingsMs, amplitudes, repeatIndex);
+				_vibrator.Call("vibrate", effect);
+			}
+			catch (System.Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Haptics PlayWaveform failed: {e.Message}");
+			}
+		}
+#endif
+
+		/// <inheritdoc />
+		public void PlayPresetOneShot(HapticPreset preset)
+		{
+#if UNITY_ANDROID && !UNITY_EDITOR
+			PlayWaveform(preset, RepeatNone);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void PlayPresetLoop(HapticPreset preset)
+		{
+#if UNITY_ANDROID && !UNITY_EDITOR
+			PlayWaveform(preset, RepeatLoop);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void PlayCustom(float intensity01, float durationMs)
+		{
+#if UNITY_ANDROID && !UNITY_EDITOR
+			if (!EnsureInitialized() || durationMs <= 0f)
+			{
+				return;
+			}
+
+			var amplitude = Mathf.Clamp(Mathf.RoundToInt(intensity01 * 255f), 1, 255);
+			var milliseconds = (long)Mathf.Round(durationMs);
+
+			try
+			{
+				using var effect = _vibrationEffectClass.CallStatic<AndroidJavaObject>(
+					"createOneShot", milliseconds, amplitude);
+				_vibrator.Call("vibrate", effect);
+			}
+			catch (System.Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Haptics PlayCustom failed: {e.Message}");
+			}
+#endif
+		}
+
+		/// <inheritdoc />
+		public void Stop()
+		{
+#if UNITY_ANDROID && !UNITY_EDITOR
+			if (!EnsureInitialized())
+			{
+				return;
+			}
+
+			try
+			{
+				_vibrator.Call("cancel");
+			}
+			catch (System.Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Haptics Stop failed: {e.Message}");
+			}
+#endif
+		}
+	}
+}
diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta
new file mode 100644
index 0000000..c23dcca
--- /dev/null
+++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8c522a85822984cf6b8782ed2956ca64
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/EditorHapticsBackend.cs b/Runtime/Haptics/Internal/EditorHapticsBackend.cs
new file mode 100644
index 0000000..7930dc9
--- /dev/null
+++ b/Runtime/Haptics/Internal/EditorHapticsBackend.cs
@@ -0,0 +1,40 @@
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Editor backend. Logs every call to the Unity console for visibility while developing;
+	/// no native haptic is produced. <see cref="IsSupported"/> is always <c>false</c> so caller
+	/// code that gates on capability behaves the same as on a real unsupported device.
+	/// </summary>
+	internal sealed class EditorHapticsBackend : IHapticsBackend
+	{
+		/// <inheritdoc />
+		public bool IsSupported => false;
+
+		/// <inheritdoc />
+		public void PlayPresetOneShot(HapticPreset preset)
+		{
+			Debug.Log($"[Haptics] PlayPresetOneShot({preset})");
+		}
+
+		/// <inheritdoc />
+		public void PlayPresetLoop(HapticPreset preset)
+		{
+			Debug.Log($"[Haptics] PlayPresetLoop({preset})");
+		}
+
+		/// <inheritdoc />
+		public void PlayCustom(float intensity01, float durationMs)
+		{
+			Debug.Log($"[Haptics] PlayCustom(intensity={intensity01:0.00}, durationMs={durationMs:0})");
+		}
+
+		/// <inheritdoc />
+		public void Stop()
+		{
+			Debug.Log("[Haptics] Stop");
+		}
+	}
+}
diff --git a/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta b/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta
new file mode 100644
index 0000000..945abac
--- /dev/null
+++ b/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e52f62169c0404270a043e679e2cd3be
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/HapticEnvelopes.cs b/Runtime/Haptics/Internal/HapticEnvelopes.cs
new file mode 100644
index 0000000..1792489
--- /dev/null
+++ b/Runtime/Haptics/Internal/HapticEnvelopes.cs
@@ -0,0 +1,71 @@
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Single source of truth for per-preset haptic envelopes — read by both
+	/// <see cref="AndroidHapticsBackend"/> and the editor envelope visualiser.
+	/// </summary>
+	internal static class HapticEnvelopes
+	{
+		/// <summary>Source-of-truth envelope for a preset, in seconds and <c>[0, 1]</c> amplitude.</summary>
+		internal static (float[] timesSec, float[] amps) GetFloatEnvelopeFor(HapticPreset preset)
+		{
+			switch (preset)
+			{
+				case HapticPreset.Selection:
+					return (new[] { 0.04f }, new[] { 0.471f });
+				case HapticPreset.Success:
+					return (new[] { 0.04f, 0.04f, 0.16f }, new[] { 0.157f, 0.0f, 1.000f });
+				case HapticPreset.Warning:
+					return (new[] { 0.12f, 0.12f, 0.04f }, new[] { 1.000f, 0.0f, 0.470f });
+				case HapticPreset.Error:
+					return (new[] { 0.08f, 0.04f, 0.08f, 0.04f, 0.16f, 0.04f, 0.04f },
+					        new[] { 0.470f, 0.0f, 0.470f, 0.0f, 1.000f, 0.0f, 0.157f });
+				case HapticPreset.ImpactLight:
+					return (new[] { 0.04f }, new[] { 0.156f });
+				case HapticPreset.ImpactMedium:
+					return (new[] { 0.08f }, new[] { 0.471f });
+				case HapticPreset.ImpactHeavy:
+					return (new[] { 0.16f }, new[] { 1.000f });
+				case HapticPreset.ImpactRigid:
+					return (new[] { 0.04f }, new[] { 1.000f });
+				case HapticPreset.ImpactSoft:
+					return (new[] { 0.16f }, new[] { 0.156f });
+				default:
+					return (new[] { 0.0f }, new[] { 0.0f });
+			}
+		}
+
+		/// <summary>
+		/// Returns the runtime-ready <c>(long[] milliseconds, int[] 0..255 amplitudes)</c> for the
+		/// given preset. Mirrors what <see cref="AndroidHapticsBackend"/> passes to
+		/// <c>VibrationEffect.createWaveform</c>.
+		/// </summary>
+		internal static (long[] timingsMs, int[] amplitudes) GetEnvelopeFor(HapticPreset preset)
+		{
+			var (timesSec, amps) = GetFloatEnvelopeFor(preset);
+			var timingsMs = new long[timesSec.Length];
+			var amplitudes = new int[amps.Length];
+			for (var i = 0; i < timesSec.Length; i++)
+			{
+				timingsMs[i] = (long)Mathf.Round(timesSec[i] * 1000f);
+				amplitudes[i] = Mathf.Clamp(Mathf.RoundToInt(amps[i] * 255f), 0, 255);
+			}
+			return (timingsMs, amplitudes);
+		}
+
+		/// <summary>Total natural duration of the preset in seconds.</summary>
+		internal static float GetNaturalDurationSeconds(HapticPreset preset)
+		{
+			var (timesSec, _) = GetFloatEnvelopeFor(preset);
+			var total = 0f;
+			for (var i = 0; i < timesSec.Length; i++)
+			{
+				total += timesSec[i];
+			}
+			return total;
+		}
+	}
+}
diff --git a/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta b/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta
new file mode 100644
index 0000000..99a9790
--- /dev/null
+++ b/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a289c7bca9e774c3ca47bd5b992b9d6e
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/HapticsHost.cs b/Runtime/Haptics/Internal/HapticsHost.cs
new file mode 100644
index 0000000..c53ba29
--- /dev/null
+++ b/Runtime/Haptics/Internal/HapticsHost.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Internal MonoBehaviour that owns the single auto-stop coroutine for time-bounded haptics.
+	/// Spawned lazily by <see cref="HapticsService"/> the first time a <c>Play*</c> call is made.
+	/// </summary>
+	internal sealed class HapticsHost : MonoBehaviour
+	{
+		private Coroutine _activeCoroutine;
+		private Action _onStop;
+
+		/// <summary>Schedules a one-time stop after <paramref name="delaySeconds"/> real-time seconds.</summary>
+		public void ScheduleStop(float delaySeconds, Action onStop)
+		{
+			Cancel();
+			_onStop = onStop;
+			_activeCoroutine = StartCoroutine(StopAfterDelay(delaySeconds));
+		}
+
+		/// <summary>Cancels any pending auto-stop. Does not invoke the stop callback.</summary>
+		public void Cancel()
+		{
+			if (_activeCoroutine != null)
+			{
+				StopCoroutine(_activeCoroutine);
+				_activeCoroutine = null;
+			}
+			_onStop = null;
+		}
+
+		private IEnumerator StopAfterDelay(float delaySeconds)
+		{
+			yield return new WaitForSecondsRealtime(delaySeconds);
+			var callback = _onStop;
+			_onStop = null;
+			_activeCoroutine = null;
+			callback?.Invoke();
+		}
+
+		private void OnDestroy()
+		{
+			Cancel();
+		}
+	}
+}
diff --git a/Runtime/Haptics/Internal/HapticsHost.cs.meta b/Runtime/Haptics/Internal/HapticsHost.cs.meta
new file mode 100644
index 0000000..1b23567
--- /dev/null
+++ b/Runtime/Haptics/Internal/HapticsHost.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6a0811ba6a7ee477fbd1a0aa4984defe
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/IHapticsBackend.cs b/Runtime/Haptics/Internal/IHapticsBackend.cs
new file mode 100644
index 0000000..d3a6c44
--- /dev/null
+++ b/Runtime/Haptics/Internal/IHapticsBackend.cs
@@ -0,0 +1,25 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Platform-specific implementation of haptic playback. Selected at construction time by
+	/// <see cref="HapticsService"/> based on the current build/runtime platform.
+	/// </summary>
+	internal interface IHapticsBackend
+	{
+		/// <summary>True when the underlying device + OS can produce at least basic vibration; false otherwise.</summary>
+		bool IsSupported { get; }
+
+		/// <summary>Play the preset's natural one-shot duration.</summary>
+		void PlayPresetOneShot(HapticPreset preset);
+
+		/// <summary>Start looping the preset; loop continues until <see cref="Stop"/> or auto-stop coroutine fires.</summary>
+		void PlayPresetLoop(HapticPreset preset);
+
+		/// <summary>Play a single custom-intensity haptic. Intensity is in <c>[0, 1]</c>; duration in milliseconds.</summary>
+		void PlayCustom(float intensity01, float durationMs);
+
+		/// <summary>Stop all active vibration immediately.</summary>
+		void Stop();
+	}
+}
diff --git a/Runtime/Haptics/Internal/IHapticsBackend.cs.meta b/Runtime/Haptics/Internal/IHapticsBackend.cs.meta
new file mode 100644
index 0000000..da18924
--- /dev/null
+++ b/Runtime/Haptics/Internal/IHapticsBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ca4fd752d08a14855bd0663f8fc110ea
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/IosHapticsBackend.cs b/Runtime/Haptics/Internal/IosHapticsBackend.cs
new file mode 100644
index 0000000..fed89f2
--- /dev/null
+++ b/Runtime/Haptics/Internal/IosHapticsBackend.cs
@@ -0,0 +1,75 @@
+#if UNITY_IOS && !UNITY_EDITOR
+using System.Runtime.InteropServices;
+using UnityEngine;
+#endif
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// iOS implementation built directly on UIKit feedback generators. Maps presets as follows:
+	/// <list type="bullet">
+	/// <item><see cref="HapticPreset.Selection"/> → <c>UISelectionFeedbackGenerator</c></item>
+	/// <item><see cref="HapticPreset.Success"/> / <see cref="HapticPreset.Warning"/> /
+	///       <see cref="HapticPreset.Error"/> → <c>UINotificationFeedbackGenerator</c></item>
+	/// <item><see cref="HapticPreset.ImpactLight"/> / <see cref="HapticPreset.ImpactMedium"/> /
+	///       <see cref="HapticPreset.ImpactHeavy"/> / <see cref="HapticPreset.ImpactRigid"/> /
+	///       <see cref="HapticPreset.ImpactSoft"/> → <c>UIImpactFeedbackGenerator</c> with the matching style</item>
+	/// </list>
+	/// Looping is performed natively by re-firing the chosen generator on an <c>NSTimer</c> until <see cref="Stop"/>.
+	/// </summary>
+	internal sealed class IosHapticsBackend : IHapticsBackend
+	{
+#if UNITY_IOS && !UNITY_EDITOR
+		[DllImport("__Internal")] private static extern void _GameLoversHapticsPreset(int presetId);
+		[DllImport("__Internal")] private static extern void _GameLoversHapticsLoopStart(int presetId);
+		[DllImport("__Internal")] private static extern void _GameLoversHapticsCustom(float intensity, float durationMs);
+		[DllImport("__Internal")] private static extern void _GameLoversHapticsStop();
+#endif
+
+		/// <inheritdoc />
+		public bool IsSupported
+		{
+			get
+			{
+#if UNITY_IOS && !UNITY_EDITOR
+				return SystemInfo.deviceType == DeviceType.Handheld;
+#else
+				return false;
+#endif
+			}
+		}
+
+		/// <inheritdoc />
+		public void PlayPresetOneShot(HapticPreset preset)
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversHapticsPreset((int)preset);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void PlayPresetLoop(HapticPreset preset)
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversHapticsLoopStart((int)preset);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void PlayCustom(float intensity01, float durationMs)
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversHapticsCustom(intensity01, durationMs);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void Stop()
+		{
+#if UNITY_IOS && !UNITY_EDITOR
+			_GameLoversHapticsStop();
+#endif
+		}
+	}
+}
diff --git a/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta b/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta
new file mode 100644
index 0000000..30a19e4
--- /dev/null
+++ b/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2ec3eb1845e014a7dbb3d4c23751deb2
\ No newline at end of file
diff --git a/Runtime/Haptics/Internal/NoOpHapticsBackend.cs b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs
new file mode 100644
index 0000000..d372363
--- /dev/null
+++ b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs
@@ -0,0 +1,25 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Haptics.Internal
+{
+	/// <summary>
+	/// Fallback backend for platforms without haptic support (desktop, WebGL, etc.).
+	/// All members are no-ops; <see cref="IsSupported"/> is always <c>false</c>.
+	/// </summary>
+	internal sealed class NoOpHapticsBackend : IHapticsBackend
+	{
+		/// <inheritdoc />
+		public bool IsSupported => false;
+
+		/// <inheritdoc />
+		public void PlayPresetOneShot(HapticPreset preset) { }
+
+		/// <inheritdoc />
+		public void PlayPresetLoop(HapticPreset preset) { }
+
+		/// <inheritdoc />
+		public void PlayCustom(float intensity01, float durationMs) { }
+
+		/// <inheritdoc />
+		public void Stop() { }
+	}
+}
diff --git a/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta
new file mode 100644
index 0000000..830db52
--- /dev/null
+++ b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 951dbdc8cf9e542e0bb3a9cef97904e8
\ No newline at end of file
diff --git a/Runtime/IMobileService.cs b/Runtime/IMobileService.cs
new file mode 100644
index 0000000..949e67a
--- /dev/null
+++ b/Runtime/IMobileService.cs
@@ -0,0 +1,81 @@
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.NativeUi;
+using GameLovers.MobileServices.Notifications;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices
+{
+	/// <summary>
+	/// Umbrella facade aggregating every Mobile Services subsystem behind a single DI registration.
+	/// Mirrors the design of <see cref="IDeviceService"/> at the package-wide level — each child is
+	/// also independently registerable / mockable.
+	/// </summary>
+	/// <remarks>
+	/// Gestures are NOT exposed here. <c>GestureController</c> is a <c>MonoBehaviour</c> consumers
+	/// attach to a scene GameObject; surfacing it through a service-locator-style facade would create
+	/// the wrong mental model (the gesture surface is per-scene, not per-app).
+	/// </remarks>
+	public interface IMobileService
+	{
+		/// <summary>Native UI (alerts, toasts, share, review).</summary>
+		INativeUiService NativeUi { get; }
+
+		/// <summary>Local notification scheduling.</summary>
+		INotificationService Notifications { get; }
+
+		/// <summary>Cross-platform haptic feedback.</summary>
+		IHapticsService Haptics { get; }
+
+		/// <summary>Device sub-services (safe area, screen wake, battery, audio session, permissions, ATT, deep link).</summary>
+		IDeviceService Device { get; }
+	}
+
+	/// <summary>
+	/// Default <see cref="IMobileService"/> implementation. Constructs every child internally using
+	/// each subsystem's default constructor — adequate for the common case. Tests should construct
+	/// the children themselves and pass them through the injection constructor.
+	/// </summary>
+	public sealed class MobileService : IMobileService, System.IDisposable
+	{
+		/// <inheritdoc />
+		public INativeUiService NativeUi { get; }
+		/// <inheritdoc />
+		public INotificationService Notifications { get; }
+		/// <inheritdoc />
+		public IHapticsService Haptics { get; }
+		/// <inheritdoc />
+		public IDeviceService Device { get; }
+
+		/// <summary>
+		/// Default constructor. Constructs <c>NativeUiServiceInstance</c>, <c>HapticsService</c>,
+		/// <c>DeviceService</c>, and a <c>MobileNotificationService</c> with a single channel named
+		/// <c>"default"</c>. For multi-channel setups, use the injection constructor instead.
+		/// </summary>
+		public MobileService() : this(
+			new NativeUiServiceInstance(),
+			new MobileNotificationService(new GameNotificationChannel("default", "Default", "Default notifications")),
+			new HapticsService(),
+			new DeviceService())
+		{
+		}
+
+		/// <summary>Injection constructor — tests pass mocks via this overload.</summary>
+		public MobileService(
+			INativeUiService nativeUi,
+			INotificationService notifications,
+			IHapticsService haptics,
+			IDeviceService device)
+		{
+			NativeUi = nativeUi;
+			Notifications = notifications;
+			Haptics = haptics;
+			Device = device;
+		}
+
+		public void Dispose()
+		{
+			(Device as System.IDisposable)?.Dispose();
+		}
+	}
+}
diff --git a/Runtime/IMobileService.cs.meta b/Runtime/IMobileService.cs.meta
new file mode 100644
index 0000000..c57743f
--- /dev/null
+++ b/Runtime/IMobileService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 243e8671a339540d0a9a52be1666f6c5
\ No newline at end of file
diff --git a/Runtime/NativeUi.meta b/Runtime/NativeUi.meta
new file mode 100644
index 0000000..8b71462
--- /dev/null
+++ b/Runtime/NativeUi.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2d77a9be33664419e9163bd93546b4ac
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/NativeUi/INativeUiService.cs b/Runtime/NativeUi/INativeUiService.cs
new file mode 100644
index 0000000..d13804a
--- /dev/null
+++ b/Runtime/NativeUi/INativeUiService.cs
@@ -0,0 +1,46 @@
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.NativeUi
+{
+	/// <summary>
+	/// Instance-based wrapper over <see cref="NativeUiService"/> for consumers who want to mock /
+	/// inject the native UI surface. The static <see cref="NativeUiService"/> remains the primary
+	/// API surface and stays the recommended path; this interface only exists to unblock test
+	/// substitution.
+	/// </summary>
+	public interface INativeUiService
+	{
+		/// <inheritdoc cref="NativeUiService.ShowAlertPopUp(bool,string,string,AlertButton[])"/>
+		void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons);
+
+		/// <inheritdoc cref="NativeUiService.ShowToastMessage(string,bool)"/>
+		void ShowToastMessage(string message, bool isLongDuration);
+
+		/// <inheritdoc cref="NativeUiService.RequestReview"/>
+		void RequestReview();
+
+		/// <inheritdoc cref="NativeUiService.Share(string,string,string,string)"/>
+		void Share(string text, string url = null, string imagePath = null, string title = null);
+	}
+
+	/// <summary>
+	/// Default implementation that forwards every call to the existing static
+	/// <see cref="NativeUiService"/>. Plain class, no fields, safe to construct any number of times.
+	/// </summary>
+	public sealed class NativeUiServiceInstance : INativeUiService
+	{
+		/// <inheritdoc />
+		public void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons)
+			=> NativeUiService.ShowAlertPopUp(isAlertSheet, title, message, buttons);
+
+		/// <inheritdoc />
+		public void ShowToastMessage(string message, bool isLongDuration)
+			=> NativeUiService.ShowToastMessage(message, isLongDuration);
+
+		/// <inheritdoc />
+		public void RequestReview() => NativeUiService.RequestReview();
+
+		/// <inheritdoc />
+		public void Share(string text, string url = null, string imagePath = null, string title = null)
+			=> NativeUiService.Share(text, url, imagePath, title);
+	}
+}
diff --git a/Runtime/NativeUi/INativeUiService.cs.meta b/Runtime/NativeUi/INativeUiService.cs.meta
new file mode 100644
index 0000000..b3e29f5
--- /dev/null
+++ b/Runtime/NativeUi/INativeUiService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c4f22f5eae86746f9bf70d87073dce82
\ No newline at end of file
diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs
new file mode 100644
index 0000000..a9e59f1
--- /dev/null
+++ b/Runtime/NativeUi/NativeUiService.cs
@@ -0,0 +1,339 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.NativeUi
+{
+	public enum AlertButtonStyle
+	{
+		Default,
+		Destructive,
+		Cancel
+	}
+
+	public struct AlertButton
+	{
+		public string Text;
+		public AlertButtonStyle Style;
+		public Action Callback;
+	}
+
+	/// <summary>
+	/// This service provides the functionality to call native UI screens
+	/// </summary>
+	public static class NativeUiService
+	{
+#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 System.Action EditorRequestReviewOverride;
+#endif
+
+		/// <summary>
+		/// Shows an alert native OS message popup with the given <paramref name="title"/>, <paramref name="message"/>
+		/// and the <paramref name="buttons"/> ordered from left to right.
+		/// If on iOS device, it can be set the pop up to be visible as an alert sheet depending on the given <paramref name="isAlertSheet"/>
+		/// </summary>
+		/// <exception cref="SystemException">
+		/// Thrown if the current platform is not iOS nor Android
+		/// </exception>
+		public static void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons)
+		{
+#if UNITY_EDITOR
+			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");
+
+			var buttonsText = new string[buttons.Length];
+			var buttonsStyle = new int[buttons.Length];
+
+			for (var i = 0; i < buttons.Length; i++)
+			{
+				buttonsText[i] = buttons[i].Text;
+				buttonsStyle[i] = (int) buttons[i].Style;
+			}
+
+			AlertMessage(isAlertSheet, title, message, buttonsText, buttonsStyle, buttons.Length, AlertButtonCallback);
+#elif UNITY_ANDROID
+			using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
+			using (var unityActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
+			using (var alertDialogBuilder = new AndroidJavaObject("android.app.AlertDialog$Builder", unityActivity))
+			using (var alertDialog = alertDialogBuilder.Call<AndroidJavaObject>("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");
+			}
+#else
+			throw new SystemException("Show an alert Pop Up is only available for iOS and Android platforms");
+#endif
+		}
+
+		/// <summary>
+		/// Shows a toast native OS message popup with the given <paramref name="message"/>.
+		/// This toast will be available on the screen for 3.5sec or 2sec depending
+		/// on the given <paramref name="isLongDuration"/> information
+		/// </summary>
+		/// <exception cref="SystemException">
+		/// Thrown if the current platform is not iOS nor Android
+		/// </exception>
+		public static void ShowToastMessage(string message, bool isLongDuration)
+		{
+#if UNITY_EDITOR
+			Debug.Log($"Show Toast message is not available in the editor and was triggered with: {message}");
+#elif UNITY_IOS
+			ToastMessage(message, isLongDuration);
+#elif UNITY_ANDROID
+			using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
+			using (var unityActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
+			using (var toastClass = new AndroidJavaClass("android.widget.Toast"))
+			{
+				var duration = isLongDuration ? toastClass.GetStatic<int>("LENGTH_LONG") : toastClass.GetStatic<int>("LENGTH_SHORT");
+				var toast = toastClass.CallStatic<AndroidJavaObject>("makeText", unityActivity, message, duration);
+
+				toast.Call("show");
+
+				toast.Dispose();
+			}
+#else
+			throw new SystemException("Show a Toast message is only available for iOS and Android platforms");
+#endif
+		}
+
+		/// <summary>
+		/// Requests an OS-mediated app rating prompt. iOS uses <c>SKStoreReviewController</c>; Android uses
+		/// the Play In-App Review API. Both platforms throttle requests internally, so calling this
+		/// frequently does NOT spam the user — the OS decides whether to actually show the prompt.
+		/// On Editor / unsupported platforms this is a safe no-op.
+		/// </summary>
+		/// <remarks>
+		/// Fire-and-forget: neither platform exposes a "was actually shown" signal (the OS may silently
+		/// suppress the prompt under its own throttling quota — that is normal and is NOT an error).
+		/// Because there is no success callback, this logs when the prompt is requested. When the
+		/// platform DOES surface an error (Android's Play flow cannot run — Play Core library missing,
+		/// the request flow failed, or launch threw), it is logged as a warning / error.
+		/// On Android the Play In-App Review library is auto-injected at build time by
+		/// <c>MobileServicesBuildPostprocessor</c> (Project Settings &gt; GameLovers &gt; Mobile Services).
+		/// If you opt out of that injection you must add it yourself to <c>mainTemplate.gradle</c>:
+		/// <c>implementation 'com.google.android.play:review:2.0.2'</c> (or newer). This never throws.
+		/// </remarks>
+		public static void RequestReview()
+		{
+#if UNITY_EDITOR
+			if (EditorRequestReviewOverride != null)
+			{
+				EditorRequestReviewOverride.Invoke();
+			}
+			else
+			{
+				Debug.Log("[GameLovers.MobileServices] RequestReview() is a no-op in the editor unless the Mobile Services Device Simulator is enabled.");
+			}
+#elif UNITY_IOS
+			// SKStoreReviewController gives no success/error callback — log that we requested it.
+			Debug.Log("[GameLovers.MobileServices] Requested App Store review prompt (SKStoreReviewController). The OS decides whether to actually show it; there is no callback.");
+			RequestReviewNative();
+#elif UNITY_ANDROID
+			RequestReviewAndroid();
+#endif
+		}
+
+		/// <summary>
+		/// Opens the OS share sheet with the given content. Any combination of <paramref name="text"/>,
+		/// <paramref name="url"/>, and <paramref name="imagePath"/> may be supplied; nulls are skipped.
+		/// <paramref name="imagePath"/> must be an absolute filesystem path. <paramref name="title"/> is
+		/// used as the chooser title (Android) and is ignored on iOS.
+		/// On Editor / unsupported platforms this is a safe no-op.
+		/// </summary>
+		public static void Share(string text, string url = null, string imagePath = null, string title = null)
+		{
+#if UNITY_EDITOR
+			Debug.Log($"Share is not available in the editor (text='{text}', url='{url}', imagePath='{imagePath}').");
+#elif UNITY_IOS
+			ShareNative(text ?? string.Empty, url ?? string.Empty, imagePath ?? string.Empty);
+#elif UNITY_ANDROID
+			ShareAndroid(text, url, imagePath, title);
+#endif
+		}
+
+#if UNITY_ANDROID
+		private static void RequestReviewAndroid()
+		{
+			try
+			{
+				using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+				using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
+				using var managerFactory = new AndroidJavaClass("com.google.android.play.core.review.ReviewManagerFactory");
+				using var manager = managerFactory.CallStatic<AndroidJavaObject>("create", activity);
+				using var requestTask = manager.Call<AndroidJavaObject>("requestReviewFlow");
+				requestTask.Call<AndroidJavaObject>("addOnCompleteListener", new ReviewFlowListener(activity, manager));
+			}
+			catch (Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] RequestReview failed: {e.Message}. " +
+					"Ensure 'com.google.android.play:review' is on the gradle classpath.");
+			}
+		}
+
+		private static void ShareAndroid(string text, string url, string imagePath, string title)
+		{
+			try
+			{
+				using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+				using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
+				using var intentClass = new AndroidJavaClass("android.content.Intent");
+				using var intent = new AndroidJavaObject("android.content.Intent");
+
+				intent.Call<AndroidJavaObject>("setAction", intentClass.GetStatic<string>("ACTION_SEND"));
+
+				var hasImage = !string.IsNullOrEmpty(imagePath);
+				if (hasImage)
+				{
+					intent.Call<AndroidJavaObject>("setType", "image/*");
+					using var uriClass = new AndroidJavaClass("android.net.Uri");
+					using var fileClass = new AndroidJavaObject("java.io.File", imagePath);
+					using var imageUri = uriClass.CallStatic<AndroidJavaObject>("fromFile", fileClass);
+					intent.Call<AndroidJavaObject>("putExtra", intentClass.GetStatic<string>("EXTRA_STREAM"), imageUri);
+					intent.Call<AndroidJavaObject>("addFlags", intentClass.GetStatic<int>("FLAG_GRANT_READ_URI_PERMISSION"));
+				}
+				else
+				{
+					intent.Call<AndroidJavaObject>("setType", "text/plain");
+				}
+
+				var combinedText = string.IsNullOrEmpty(url) ? text : (string.IsNullOrEmpty(text) ? url : text + " " + url);
+				if (!string.IsNullOrEmpty(combinedText))
+				{
+					intent.Call<AndroidJavaObject>("putExtra", intentClass.GetStatic<string>("EXTRA_TEXT"), combinedText);
+				}
+
+				using var chooser = intentClass.CallStatic<AndroidJavaObject>("createChooser", intent, title ?? string.Empty);
+				activity.Call("startActivity", chooser);
+			}
+			catch (Exception e)
+			{
+				Debug.LogError($"[GameLovers.MobileServices] Share failed: {e.Message}");
+			}
+		}
+
+		private class ReviewFlowListener : AndroidJavaProxy
+		{
+			private readonly AndroidJavaObject _activity;
+			private readonly AndroidJavaObject _manager;
+
+			// Modern Play In-App Review (v2.x of com.google.android.play:review) uses Google Play Services Tasks.
+			// The legacy com.google.android.play.core.tasks.OnCompleteListener applies to the deprecated
+			// monolithic com.google.android.play:core library only — do not use it here.
+			public ReviewFlowListener(AndroidJavaObject activity, AndroidJavaObject manager)
+				: base("com.google.android.gms.tasks.OnCompleteListener")
+			{
+				_activity = activity;
+				_manager = manager;
+			}
+
+			// ReSharper disable once InconsistentNaming
+			public void onComplete(AndroidJavaObject task)
+			{
+				try
+				{
+					if (!task.Call<bool>("isSuccessful"))
+					{
+						Debug.LogWarning("[GameLovers.MobileServices] requestReviewFlow returned an unsuccessful task — the review prompt cannot be shown.");
+						return;
+					}
+
+					using var reviewInfo = task.Call<AndroidJavaObject>("getResult");
+					using var launchTask = _manager.Call<AndroidJavaObject>("launchReviewFlow", _activity, reviewInfo);
+					_ = launchTask;
+					// launchReviewFlow's task completes when the flow finishes, but the OS gives no
+					// "was actually displayed" signal (it may be suppressed by quota — not an error).
+					Debug.Log("[GameLovers.MobileServices] Launched Play In-App Review flow. The OS decides whether to actually show it; there is no shown callback.");
+				}
+				catch (Exception e)
+				{
+					Debug.LogError($"[GameLovers.MobileServices] launchReviewFlow failed: {e.Message}");
+				}
+			}
+		}
+#endif
+
+#if UNITY_IOS
+		internal delegate void AlertButtonDelegate(string buttonText);
+
+		[System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversAlertMessage")]
+		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 = "_GameLoversToastMessage")]
+		private static extern void ToastMessage(string message, bool isLongDuration);
+
+		[System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversRequestReview")]
+		private static extern void RequestReviewNative();
+
+		[System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversShare")]
+		private static extern void ShareNative(string text, string url, string imagePath);
+
+		[AOT.MonoPInvokeCallback(typeof(AlertButtonDelegate))]
+		private static void AlertButtonCallback(string buttonText)
+		{
+			if (_currentButtons == null)
+			{
+				return;
+			}
+
+			foreach (var button in _currentButtons)
+			{
+				if (button.Text == buttonText)
+				{
+					button.Callback?.Invoke();
+					break;
+				}
+			}
+		}
+
+		private static AlertButton[] _currentButtons;
+#elif UNITY_ANDROID
+		private class AndroidButtonCallback : AndroidJavaProxy
+		{
+			private readonly Action _callback;
+
+			public AndroidButtonCallback(Action callback) : base("android.content.DialogInterface$OnClickListener")
+			{
+				_callback = callback;
+			}
+
+			// ReSharper disable once InconsistentNaming
+			public void onClick(AndroidJavaObject dialog, int which)
+			{
+				dialog.Call("dismiss");
+
+				_callback();
+			}
+		}
+
+		private static int ConvertToAndroidStyle(AlertButtonStyle style)
+		{
+			switch (style)
+			{
+				case AlertButtonStyle.Default:
+					return -3;
+				case AlertButtonStyle.Destructive:
+					return -1;
+				case AlertButtonStyle.Cancel:
+					return -2;
+				default:
+					throw new ArgumentOutOfRangeException(nameof(style), style, "Wrong given style");
+			}
+		}
+#endif
+	}
+}
\ No newline at end of file
diff --git a/Runtime/NativeUi/NativeUiService.cs.meta b/Runtime/NativeUi/NativeUiService.cs.meta
new file mode 100644
index 0000000..c625719
--- /dev/null
+++ b/Runtime/NativeUi/NativeUiService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4db7380d1652a4742ba67abe1dc9c2eb
\ No newline at end of file
diff --git a/Runtime/NativeUiService.cs b/Runtime/NativeUiService.cs
deleted file mode 100644
index 01a1c83..0000000
--- a/Runtime/NativeUiService.cs
+++ /dev/null
@@ -1,169 +0,0 @@
-using System;
-using UnityEngine;
-
-// ReSharper disable once CheckNamespace
-
-namespace GameLovers.NativeUi
-{
-	public enum AlertButtonStyle
-	{
-		Default,
-		Positive,
-		Negative
-	}
-
-	public struct AlertButton
-	{
-		public string Text;
-		public AlertButtonStyle Style;
-		public Action Callback;
-	}
-	
-	/// <summary>
-	/// This service provides the functionality to call native UI screens
-	/// </summary>
-	public static class NativeUiService
-	{
-		/// <summary>
-		/// Shows an alert native OS message popup with the given <paramref name="title"/>, <paramref name="message"/>
-		/// and the <paramref name="buttons"/> ordered from left to right.
-		/// If on iOS device, it can be set the pop up to be visible as an alert sheet depending on the given <paramref name="isAlertSheet"/>
-		/// </summary>
-		/// <exception cref="SystemException">
-		/// Thrown if the current platform is not iOS nor Android
-		/// </exception>
-		public static void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons)
-		{
-#if UNITY_EDITOR
-			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");
-
-			var buttonsText = new string[buttons.Length];
-			var buttonsStyle = new int[buttons.Length];
-
-			for (var i = 0; i < buttons.Length; i++)
-			{
-				buttonsText[i] = buttons[i].Text;
-				buttonsStyle[i] = (int) buttons[i].Style;
-			}
-
-			AlertMessage(isAlertSheet, title, message, buttonsText, buttonsStyle, buttons.Length, AlertButtonCallback);
-#elif UNITY_ANDROID
-			using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
-			using (var unityActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
-			using (var alertDialogBuilder = new AndroidJavaObject("android.app.AlertDialog$Builder", unityActivity))
-			using (var alertDialog = alertDialogBuilder.Call<AndroidJavaObject>("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");
-			}
-#else
-			throw new SystemException("Show an alert Pop Up is only available for iOS and Android platforms");
-#endif
-		}
-
-		/// <summary>
-		/// Shows a toast native OS message popup with the given <paramref name="message"/>.
-		/// This toast will be available on the screen for 3.5sec or 2sec depending
-		/// on the given <paramref name="isLongDuration"/> information
-		/// </summary>
-		/// <exception cref="SystemException">
-		/// Thrown if the current platform is not iOS nor Android
-		/// </exception>
-		public static void ShowToastMessage(string message, bool isLongDuration)
-		{
-#if UNITY_EDITOR
-			Debug.Log($"Show Toast message is not available in the editor and was triggered with: {message}");
-#elif UNITY_IOS
-			ToastMessage(message, isLongDuration);
-#elif UNITY_ANDROID
-			using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
-			using (var unityActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
-			using (var toastClass = new AndroidJavaClass("android.widget.Toast"))
-			{
-				var duration = isLongDuration ? toastClass.GetStatic<int>("LENGTH_LONG") : toastClass.GetStatic<int>("LENGTH_SHORT");
-				var toast = toastClass.CallStatic<AndroidJavaObject>("makeText", unityActivity, message, duration);
-				
-				toast.Call("show");
-				
-				toast.Dispose();
-			}
-#else
-			throw new SystemException("Show a Toast message is only available for iOS and Android platforms");
-#endif
-		}
-		
-#if UNITY_IOS
-		internal delegate void AlertButtonDelegate(string buttonText);
-		
-		[System.Runtime.InteropServices.DllImport("__Internal")] 
-		private static extern void AlertMessage(bool isSheet, string title, string message, string[] buttonsText, 
-			int[] buttonsStyle, int buttonsLength, AlertButtonDelegate alertButtonCallback);
-		
-		[System.Runtime.InteropServices.DllImport("__Internal")] 
-		private static extern void ToastMessage(string message, bool isLongDuration);
-
-		[AOT.MonoPInvokeCallback(typeof(AlertButtonDelegate))]
-		private static void AlertButtonCallback(string buttonText)
-		{
-			if (_currentButtons == null)
-			{
-				return;
-			}
-
-			foreach (var button in _currentButtons)
-			{
-				if (button.Text == buttonText)
-				{
-					button.Callback?.Invoke();
-					break;
-				}
-			}
-		}
-
-		private static AlertButton[] _currentButtons;
-#elif UNITY_ANDROID
-		private class AndroidButtonCallback : AndroidJavaProxy
-		{
-			private readonly Action _callback;
-			
-			public AndroidButtonCallback(Action callback) : base("android.content.DialogInterface$OnClickListener")
-			{
-				_callback = callback;
-			}
-
-			// ReSharper disable once InconsistentNaming
-			public void onClick(AndroidJavaObject dialog, int which)
-			{
-				dialog.Call("dismiss");
-
-				_callback();
-			}
-		}
-
-		private static int ConvertToAndroidStyle(AlertButtonStyle style)
-		{
-			switch (style)
-			{
-				case AlertButtonStyle.Default:
-					return -3;
-				case AlertButtonStyle.Positive:
-					return -1;
-				case AlertButtonStyle.Negative:
-					return -2;
-				default:
-					throw new ArgumentOutOfRangeException(nameof(style), style, "Wrong given style");
-			}
-		}
-#endif
-	}
-}
\ No newline at end of file
diff --git a/Runtime/Notifications.meta b/Runtime/Notifications.meta
new file mode 100644
index 0000000..96c8cc5
--- /dev/null
+++ b/Runtime/Notifications.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 48c03ed16911b4f95b405397b757bb61
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/Android.meta b/Runtime/Notifications/Android.meta
new file mode 100644
index 0000000..97dba0d
--- /dev/null
+++ b/Runtime/Notifications/Android.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 3502381b381e449c4b4a7706e4bcc0da
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/Android/AndroidGameNotification.cs b/Runtime/Notifications/Android/AndroidGameNotification.cs
new file mode 100644
index 0000000..45743ef
--- /dev/null
+++ b/Runtime/Notifications/Android/AndroidGameNotification.cs
@@ -0,0 +1,114 @@
+#if UNITY_ANDROID
+using System;
+using Unity.Notifications.Android;
+using UnityEngine.Assertions;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Android specific implementation of <see cref="IGameNotification"/>.
+    /// </summary>
+    public class AndroidGameNotification : IGameNotification
+    {
+        private AndroidNotification internalNotification;
+
+        /// <summary>
+        /// Gets the internal notification object used by the mobile notifications system.
+        /// </summary>
+        public AndroidNotification InternalNotification => internalNotification;
+
+        /// <inheritdoc />
+        /// <summary>
+        /// On Android, if the ID isn't explicitly set, it will be generated after it has been scheduled.
+        /// </summary>
+        public int? Id { get; set; }
+
+        /// <inheritdoc />
+        public string Title { get => InternalNotification.Title; set => internalNotification.Title = value; }
+
+        /// <inheritdoc />
+        public string Body { get => InternalNotification.Text; set => internalNotification.Text = value; }
+
+        /// <summary>
+        /// Does nothing on Android.
+        /// </summary>
+        public string Subtitle { get => null; set {} }
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// On Android, this represents the notification's channel, and is required. Will be configured automatically by
+        /// <see cref="AndroidNotificationsPlatform"/> if <see cref="AndroidNotificationsPlatform.DefaultChannelId"/> is set
+        /// </remarks>
+        /// <value>The value of <see cref="DeliveredChannel"/>.</value>
+        public string Channel { get => DeliveredChannel; set => DeliveredChannel = value; }
+
+        /// <inheritdoc />
+        public int? BadgeNumber
+        {
+            get => internalNotification.Number != -1 ? internalNotification.Number : (int?)null;
+            set => internalNotification.Number = value ?? -1;
+        }
+
+        /// <inheritdoc />
+        public bool ShouldAutoCancel
+        {
+            get => InternalNotification.ShouldAutoCancel;
+            set => internalNotification.ShouldAutoCancel = value;
+        }
+
+        /// <inheritdoc />
+        public DateTime? DeliveryTime
+        {
+            get => InternalNotification.FireTime;
+            set => internalNotification.FireTime = value ?? throw new ArgumentNullException(nameof(value));
+        }
+
+        /// <summary>
+        /// Gets or sets the channel for this notification.
+        /// </summary>
+        public string DeliveredChannel { get; set; }
+
+        /// <inheritdoc />
+        public bool Scheduled { get; private set; }
+
+        /// <inheritdoc />
+        public string SmallIcon { get => InternalNotification.SmallIcon; set => internalNotification.SmallIcon = value; }
+
+        /// <inheritdoc />
+        public string LargeIcon { get => InternalNotification.LargeIcon; set => internalNotification.LargeIcon = value; }
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="AndroidGameNotification"/>.
+        /// </summary>
+        public AndroidGameNotification()
+        {
+            internalNotification = new AndroidNotification();
+        }
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="AndroidGameNotification"/> from a delivered notification
+        /// </summary>
+        /// <param name="deliveredNotification">The notification that has been delivered.</param>
+        /// <param name="deliveredId">The ID of the delivered notification.</param>
+        /// <param name="deliveredChannel">The channel the notification was delivered to.</param>
+        internal AndroidGameNotification(AndroidNotification deliveredNotification, int deliveredId,
+                                         string deliveredChannel)
+        {
+            internalNotification = deliveredNotification;
+            Id = deliveredId;
+            DeliveredChannel = deliveredChannel;
+        }
+
+        /// <summary>
+        /// Set the scheduled flag.
+        /// </summary>
+        internal void OnScheduled()
+        {
+            Assert.IsFalse(Scheduled);
+            Scheduled = true;
+        }
+    }
+}
+#endif
diff --git a/Runtime/NativeUiService.cs.meta b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta
similarity index 83%
rename from Runtime/NativeUiService.cs.meta
rename to Runtime/Notifications/Android/AndroidGameNotification.cs.meta
index 96264fc..c7b9d1f 100644
--- a/Runtime/NativeUiService.cs.meta
+++ b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: 1411576e1e91848ef92fed2e4388d4d6
+guid: 4bed07ce117594a94a3ab7596e22ea2b
 MonoImporter:
   externalObjects: {}
   serializedVersion: 2
diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs
new file mode 100644
index 0000000..e935582
--- /dev/null
+++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs
@@ -0,0 +1,182 @@
+#if UNITY_ANDROID
+using System;
+using System.Linq;
+using Unity.Notifications.Android;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Android implementation of <see cref="IGameNotificationsPlatform"/>.
+    /// </summary>
+    internal class AndroidNotificationsPlatform : IGameNotificationsPlatform<AndroidGameNotification>,
+        IDisposable
+    {
+        /// <inheritdoc />
+        public event Action<IGameNotification> NotificationReceived;
+
+        /// <summary>
+        /// Gets or sets the default channel ID for notifications.
+        /// </summary>
+        /// <value>The default channel ID for new notifications, or null.</value>
+        public string DefaultChannelId { get; set; }
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="AndroidNotificationsPlatform"/>.
+        /// </summary>
+        public AndroidNotificationsPlatform()
+        {
+            AndroidNotificationCenter.OnNotificationReceived += OnLocalNotificationReceived;
+        }
+
+        /// <summary>
+        /// Registers the given <seealso cref="AndroidNotificationChannel"/> for the Android
+        /// </summary>
+        public void RegisterChannel(GameNotificationChannel notificationChannel)
+        {
+            long[] vibrationPattern = null;
+            if (notificationChannel.VibrationPattern != null)
+            {
+                vibrationPattern = notificationChannel.VibrationPattern.Select(v => (long)v).ToArray();
+            }
+            
+            var channel = new AndroidNotificationChannel(notificationChannel.Id, notificationChannel.Name,
+                notificationChannel.Description, (Importance)notificationChannel.Style)
+            {
+                CanBypassDnd = notificationChannel.HighPriority,
+                CanShowBadge = notificationChannel.ShowsBadge,
+                EnableLights = notificationChannel.ShowLights,
+                EnableVibration = notificationChannel.Vibrates,
+                LockScreenVisibility = (LockScreenVisibility)notificationChannel.Privacy,
+                VibrationPattern = vibrationPattern
+            };
+
+            AndroidNotificationCenter.RegisterNotificationChannel(channel);
+        }
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// Will set the <see cref="AndroidGameNotification.Id"/> field of <paramref name="gameNotification"/>.
+        /// </remarks>
+        public void ScheduleNotification(AndroidGameNotification gameNotification)
+        {
+            if (gameNotification == null)
+            {
+                throw new ArgumentNullException(nameof(gameNotification));
+            }
+
+            if (gameNotification.Id.HasValue)
+            {
+                AndroidNotificationCenter.SendNotificationWithExplicitID(gameNotification.InternalNotification,
+                    gameNotification.DeliveredChannel,
+                    gameNotification.Id.Value);
+            }
+            else
+            {
+                int notificationId = AndroidNotificationCenter.SendNotification(gameNotification.InternalNotification,
+                    gameNotification.DeliveredChannel);
+                gameNotification.Id = notificationId;
+            }
+
+            gameNotification.OnScheduled();
+        }
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// Will set the <see cref="AndroidGameNotification.Id"/> field of <paramref name="gameNotification"/>.
+        /// </remarks>
+        public void ScheduleNotification(IGameNotification gameNotification)
+        {
+            if (gameNotification == null)
+            {
+                throw new ArgumentNullException(nameof(gameNotification));
+            }
+
+            if (!(gameNotification is AndroidGameNotification androidNotification))
+            {
+                throw new InvalidOperationException(
+                    "Notification provided to ScheduleNotification isn't an AndroidGameNotification.");
+            }
+
+            ScheduleNotification(androidNotification);
+        }
+
+        /// <inheritdoc />
+        /// <summary>
+        /// Create a new <see cref="AndroidGameNotification" />.
+        /// </summary>
+        public AndroidGameNotification CreateNotification()
+        {
+            var notification = new AndroidGameNotification()
+            {
+                DeliveredChannel = DefaultChannelId
+            };
+
+            return notification;
+        }
+
+        /// <inheritdoc />
+        /// <summary>
+        /// Create a new <see cref="AndroidGameNotification" />.
+        /// </summary>
+        IGameNotification IGameNotificationsPlatform.CreateNotification()
+        {
+            return CreateNotification();
+        }
+
+        /// <inheritdoc />
+        public void CancelNotification(int notificationId)
+        {
+            AndroidNotificationCenter.CancelScheduledNotification(notificationId);
+        }
+
+        /// <inheritdoc />
+        /// <summary>
+        /// Not currently implemented on Android
+        /// </summary>
+        public void DismissNotification(int notificationId)
+        {
+            AndroidNotificationCenter.CancelDisplayedNotification(notificationId);
+        }
+
+        /// <inheritdoc />
+        public void CancelAllScheduledNotifications()
+        {
+            AndroidNotificationCenter.CancelAllScheduledNotifications();
+        }
+
+        /// <inheritdoc />
+        public void DismissAllDisplayedNotifications()
+        {
+            AndroidNotificationCenter.CancelAllDisplayedNotifications();
+        }
+
+        /// <summary>
+        /// Does nothing on Android.
+        /// </summary>
+        public void OnForeground() {}
+
+        /// <summary>
+        /// Does nothing on Android.
+        /// </summary>
+        public void OnBackground() {}
+
+        /// <summary>
+        /// Unregister delegates.
+        /// </summary>
+        public void Dispose()
+        {
+            AndroidNotificationCenter.OnNotificationReceived -= OnLocalNotificationReceived;
+        }
+
+        // Event handler for receiving local notifications.
+        private void OnLocalNotificationReceived(AndroidNotificationIntentData data)
+        {
+            // Create a new AndroidGameNotification out of the delivered notification, but only
+            // if the event is registered
+            NotificationReceived?.Invoke(new AndroidGameNotification(data.Notification, data.Id, data.Channel));
+        }
+    }
+}
+#endif
diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta
new file mode 100644
index 0000000..d8d7cc0
--- /dev/null
+++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 56d5130c0d2f644b1ae6b3ddce2ce6a4
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/GameNotificationChannel.cs b/Runtime/Notifications/GameNotificationChannel.cs
new file mode 100644
index 0000000..775a74a
--- /dev/null
+++ b/Runtime/Notifications/GameNotificationChannel.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Linq;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Cross-platform wrapper to represent channels for notifications.
+    /// </summary>
+    /// <remarks>
+    /// <para>On Android, this maps pretty closely to Android Notification Channels. On iOS, this does nothing.</para>
+    /// <para>For projects targeting Android, you need to have at least one channel.</para>
+    /// </remarks>
+    public readonly struct GameNotificationChannel
+    {
+        /// <summary>
+        /// The style of notification shown for this channel. Corresponds to the Importance setting of
+        /// an Android notification, and do nothing on iOS.
+        /// </summary>
+        public enum NotificationStyle
+        {
+            /// <summary>
+            /// Notification does not appear in the status bar.
+            /// </summary>
+            None = 0,
+            /// <summary>
+            /// Notification makes no sound.
+            /// </summary>
+            NoSound = 2,
+            /// <summary>
+            /// Notification plays sound.
+            /// </summary>
+            Default = 3,
+            /// <summary>
+            /// Notification also displays a heads-up popup.
+            /// </summary>
+            Popup = 4
+        }
+
+        /// <summary>
+        /// Controls how notifications display on the device lock screen.
+        /// </summary>
+        public enum PrivacyMode
+        {
+            /// <summary>
+            /// Notifications aren't shown on secure lock screens.
+            /// </summary>
+            Secret = -1,
+            /// <summary>
+            /// Notifications display an icon, but content is concealed on secure lock screens.
+            /// </summary>
+            Private = 0,
+            /// <summary>
+            /// Notifications display on all lock screens.
+            /// </summary>
+            Public
+        }
+
+        /// <summary>
+        /// The identifier for the channel.
+        /// </summary>
+        public readonly string Id;
+
+        /// <summary>
+        /// The name of the channel as displayed to the user.
+        /// </summary>
+        public readonly string Name;
+
+        /// <summary>
+        /// The description of the channel as displayed to the user.
+        /// </summary>
+        public readonly string Description;
+
+        /// <summary>
+        /// A flag determining whether messages on this channel can show a badge. Defaults to true.
+        /// </summary>
+        public readonly bool ShowsBadge;
+
+        /// <summary>
+        /// A flag determining whether messages on this channel cause the device light to flash. Defaults to false.
+        /// </summary>
+        public readonly bool ShowLights;
+
+        /// <summary>
+        /// A flag determining whether messages on this channel cause the device to vibrate. Defaults to true.
+        /// </summary>
+        public readonly bool Vibrates;
+
+        /// <summary>
+        /// A flag determining whether messages on this channel bypass do not disturb settings. Defaults to false.
+        /// </summary>
+        public readonly bool HighPriority;
+
+        /// <summary>
+        /// The display style for this notification. Defaults to <see cref="NotificationStyle.Popup"/>.
+        /// </summary>
+        public readonly NotificationStyle Style;
+
+        /// <summary>
+        /// The privacy setting for this notification. Defaults to <see cref="PrivacyMode.Public"/>.
+        /// </summary>
+        public readonly PrivacyMode Privacy;
+
+        /// <summary>
+        /// The custom vibration pattern for this channel. Set to null to use the default.
+        /// </summary>
+        public readonly int[] VibrationPattern;
+
+        /// <summary>
+        /// Initialize a new instance of <see cref="GameNotificationChannel"/> with
+        /// optional fields set to their default values.
+        /// </summary>
+        public GameNotificationChannel(string id, string name, string description) : this()
+        {
+            Id = id;
+            Name = name;
+            Description = description;
+
+            ShowsBadge = true;
+            ShowLights = false;
+            Vibrates = true;
+            HighPriority = false;
+            Style = NotificationStyle.Popup;
+            Privacy = PrivacyMode.Public;
+            VibrationPattern = null;
+        }
+
+        /// <summary>
+        /// Initialize a new instance of <see cref="GameNotificationChannel"/>, providing the notification style
+        /// and optionally all other settings.
+        /// </summary>
+        public GameNotificationChannel(string id, string name, string description, NotificationStyle style, bool showsBadge = true, bool showLights = false, bool vibrates = true, bool highPriority = false, PrivacyMode privacy = PrivacyMode.Public, long[] vibrationPattern = null)
+        {
+            Id = id;
+            Name = name;
+            Description = description;
+            ShowsBadge = showsBadge;
+            ShowLights = showLights;
+            Vibrates = vibrates;
+            HighPriority = highPriority;
+            Style = style;
+            Privacy = privacy;
+            if (vibrationPattern != null)
+                VibrationPattern = vibrationPattern.Select(v => (int)v).ToArray();
+            else
+                VibrationPattern = null;
+        }
+    }
+}
diff --git a/Runtime/Notifications/GameNotificationChannel.cs.meta b/Runtime/Notifications/GameNotificationChannel.cs.meta
new file mode 100644
index 0000000..c35f1a4
--- /dev/null
+++ b/Runtime/Notifications/GameNotificationChannel.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dada65c72c6b34e4aa74dd72ef283fef
\ No newline at end of file
diff --git a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs
new file mode 100644
index 0000000..921bcf3
--- /dev/null
+++ b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs
@@ -0,0 +1,539 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// The operating modes for the notifications manager
+    /// </summary>
+    [Flags]
+    public enum OperatingMode
+    {
+        /// <summary>
+        /// Do not perform any queueing at all. All notifications are scheduled with the operating system
+        /// immediately.
+        /// </summary>
+        NoQueue = 0x00,
+
+        /// <summary>
+        /// <para>
+        /// Queue messages that are scheduled with this manager.
+        /// No messages will be sent to the operating system until the application is backgrounded.
+        /// </para>
+        /// <para>
+        /// If badge numbers are not set, will automatically increment them. This will only happen if NO badge numbers
+        /// for pending notifications are ever set.
+        /// </para>
+        /// </summary>
+        Queue = 0x01,
+
+        /// <summary>
+        /// When the application is foregrounded, clear all pending notifications.
+        /// </summary>
+        ClearOnForegrounding = 0x02,
+
+        /// <summary>
+        /// After clearing events, will put future ones back into the queue if they are marked with <see cref="PendingNotification.Reschedule"/>.
+        /// </summary>
+        /// <remarks>
+        /// Only valid if <see cref="ClearOnForegrounding"/> is also set.
+        /// </remarks>
+        RescheduleAfterClearing = 0x04,
+
+        /// <summary>
+        /// Combines the behaviour of <see cref="Queue"/> and <see cref="ClearOnForegrounding"/>.
+        /// </summary>
+        QueueAndClear = Queue | ClearOnForegrounding,
+
+        /// <summary>
+        /// <para>
+        /// Combines the behaviour of <see cref="Queue"/>, <see cref="ClearOnForegrounding"/> and
+        /// <see cref="RescheduleAfterClearing"/>.
+        /// </para>
+        /// <para>
+        /// Ensures that messages will never be displayed while the application is in the foreground.
+        /// </para>
+        /// </summary>
+        QueueClearAndReschedule = Queue | ClearOnForegrounding | RescheduleAfterClearing,
+    }
+        
+    /// <summary>
+    /// Global notifications manager that serves as a wrapper for multiple platforms' notification systems.
+    /// </summary>
+    public sealed class GameNotificationsMonoBehaviour : MonoBehaviour
+    {
+        
+        
+        // Minimum amount of time that a notification should be into the future before it's queued when we background.
+        private static readonly TimeSpan _minimumNotificationTime = new TimeSpan(0, 0, 2);
+
+        /// <summary>
+        /// The operating mode for the notifications manager
+        /// </summary>
+        public OperatingMode Mode = OperatingMode.NoQueue;
+
+        /// <summary>
+        /// Check to make the notifications manager automatically set badge numbers so that they increment.
+        /// Schedule notifications with no numbers manually set to make use of this feature.
+        /// </summary>
+        public bool AutoBadging = true;
+
+        /// <summary>
+        /// Event fired when a scheduled local notification is delivered while the app is in the foreground.
+        /// </summary>
+        public Action<PendingNotification> OnLocalNotificationDelivered;
+
+        /// <summary>
+        /// Event fired when a queued local notification is cancelled because the application is in the foreground
+        /// when it was meant to be displayed.
+        /// </summary>
+        /// <seealso cref="OperatingMode.Queue"/>
+        public Action<PendingNotification> OnLocalNotificationExpired;
+
+        private IGameNotificationsPlatform _platform;
+        private bool _inForeground = true;
+
+        /// <summary>
+        /// Gets a collection of notifications that are scheduled or queued.
+        /// </summary>
+        public List<PendingNotification> PendingNotifications { get; private set; } = new List<PendingNotification>();
+
+        /// <summary>
+        /// Gets whether this manager has been initialized.
+        /// </summary>
+        public bool Initialized { get; private set; }
+
+        /// <summary>
+        /// Clean up platform object if necessary
+        /// </summary>
+        private void OnDestroy()
+        {
+            if (_platform == null)
+            {
+                return;
+            }
+
+            _platform.NotificationReceived -= OnNotificationReceived;
+            if (_platform is IDisposable disposable)
+            {
+                disposable.Dispose();
+            }
+
+            _inForeground = false;
+        }
+
+        /// <summary>
+        /// Check pending list for expired notifications, when in queue mode.
+        /// </summary>
+        private void Update()
+        {
+            if ((Mode & OperatingMode.Queue) != OperatingMode.Queue)
+            {
+                return;
+            }
+
+            // Check each pending notification for expiry, then remove it
+            for (int i = PendingNotifications.Count - 1; i >= 0; --i)
+            {
+                PendingNotification queuedNotification = PendingNotifications[i];
+                DateTime? time = queuedNotification.Notification.DeliveryTime;
+                if (time != null && time < DateTime.Now)
+                {
+                    PendingNotifications.RemoveAt(i);
+                    OnLocalNotificationExpired?.Invoke(queuedNotification);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Respond to application foreground/background events.
+        /// </summary>
+        private void OnApplicationFocus(bool hasFocus)
+        {
+            if (_platform == null || !Initialized)
+            {
+                return;
+            }
+
+            _inForeground = hasFocus;
+
+            if (hasFocus)
+            {
+                OnForegrounding();
+
+                return;
+            }
+
+            _platform.OnBackground();
+
+            // Backgrounding. Queue future dated notifications
+            if ((Mode & OperatingMode.Queue) == OperatingMode.Queue)
+            {
+                // Filter out past events
+                for (var i = PendingNotifications.Count - 1; i >= 0; i--)
+                {
+                    PendingNotification pendingNotification = PendingNotifications[i];
+                    // Ignore already scheduled ones
+                    if (pendingNotification.Notification.Scheduled)
+                    {
+                        continue;
+                    }
+
+                    // If a non-scheduled notification is in the past (or not within our threshold)
+                    // just remove it immediately
+                    if (pendingNotification.Notification.DeliveryTime != null &&
+                        pendingNotification.Notification.DeliveryTime - DateTime.Now < _minimumNotificationTime)
+                    {
+                        PendingNotifications.RemoveAt(i);
+                    }
+                }
+
+                // Sort notifications by delivery time, if no notifications have a badge number set
+                bool noBadgeNumbersSet =
+                    PendingNotifications.All(notification => notification.Notification.BadgeNumber == null);
+
+                if (noBadgeNumbersSet && AutoBadging)
+                {
+                    PendingNotifications.Sort((a, b) =>
+                    {
+                        if (!a.Notification.DeliveryTime.HasValue)
+                        {
+                            return 1;
+                        }
+
+                        if (!b.Notification.DeliveryTime.HasValue)
+                        {
+                            return -1;
+                        }
+
+                        return a.Notification.DeliveryTime.Value.CompareTo(b.Notification.DeliveryTime.Value);
+                    });
+
+                    // Set badge numbers incrementally
+                    var badgeNum = 1;
+                    foreach (var pendingNotification in PendingNotifications)
+                    {
+                        if (pendingNotification.Notification.DeliveryTime.HasValue &&
+                            !pendingNotification.Notification.Scheduled)
+                        {
+                            pendingNotification.Notification.BadgeNumber = badgeNum++;
+                        }
+                    }
+                }
+
+                for (int i = PendingNotifications.Count - 1; i >= 0; i--)
+                {
+                    var pendingNotification = PendingNotifications[i];
+                    // Ignore already scheduled ones
+                    if (pendingNotification.Notification.Scheduled)
+                    {
+                        continue;
+                    }
+
+                    // Schedule it now
+                    _platform.ScheduleNotification(pendingNotification.Notification);
+                }
+
+                // Clear badge numbers again (for saving)
+                if (noBadgeNumbersSet && AutoBadging)
+                {
+                    foreach (var pendingNotification in PendingNotifications)
+                    {
+                        if (pendingNotification.Notification.DeliveryTime.HasValue)
+                        {
+                            pendingNotification.Notification.BadgeNumber = null;
+                        }
+                    }
+                }
+            }
+
+            // Calculate notifications to save
+            var notificationsToSave = new List<SerializableNotification>(PendingNotifications.Count);
+            foreach (var pendingNotification in PendingNotifications)
+            {
+                // If we're in clear mode, add nothing unless we're in rescheduling mode
+                // Otherwise add everything
+                if ((Mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding)
+                {
+                    if ((Mode & OperatingMode.RescheduleAfterClearing) != OperatingMode.RescheduleAfterClearing)
+                    {
+                        continue;
+                    }
+
+                    // In reschedule mode, add ones that have been scheduled, are marked for
+                    // rescheduling, and that have a time
+                    if (pendingNotification.Reschedule &&
+                        pendingNotification.Notification.Scheduled &&
+                        pendingNotification.Notification.DeliveryTime.HasValue)
+                    {
+                        notificationsToSave.Add(pendingNotification.AsSerializableNotification());
+                    }
+                }
+                else
+                {
+                    // In non-clear mode, just add all scheduled notifications
+                    if (pendingNotification.Notification.Scheduled)
+                    {
+                        notificationsToSave.Add(pendingNotification.AsSerializableNotification());
+                    }
+                }
+            }
+
+            // Save to disk
+            PlayerPrefs.SetString("notifications", JsonUtility.ToJson(notificationsToSave));
+        }
+
+        /// <summary>
+        /// Initialize the notifications manager.
+        /// </summary>
+        /// <param name="channels">An optional collection of channels to register, for Android</param>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has already been called.</exception>
+        public void Initialize(params GameNotificationChannel[] channels)
+        {
+            if (Initialized)
+            {
+                throw new InvalidOperationException("NotificationsManager already initialized.");
+            }
+
+            Initialized = true;
+
+#if UNITY_ANDROID
+            _platform = new AndroidNotificationsPlatform();
+
+            // Register the notification channels
+            var doneDefault = false;
+            foreach (var notificationChannel in channels)
+            {
+                var platform = _platform as AndroidNotificationsPlatform;
+                
+                if (!doneDefault)
+                {
+                    doneDefault = true;
+                    platform.DefaultChannelId = notificationChannel.Id;
+                }
+
+                platform.RegisterChannel(notificationChannel);
+            }
+#elif UNITY_IOS
+            _platform = new iOSNotificationsPlatform();
+#endif
+
+            if (_platform == null)
+            {
+                return;
+            }
+
+            _platform.NotificationReceived += OnNotificationReceived;
+
+            OnForegrounding();
+        }
+
+        /// <summary>
+        /// Creates a new notification object for the current platform.
+        /// </summary>
+        /// <returns>The new notification, ready to be scheduled, or null if there's no valid platform.</returns>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has not been called.</exception>
+        public IGameNotification CreateNotification()
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            return _platform?.CreateNotification();
+        }
+
+        /// <summary>
+        /// Schedules a notification to be delivered.
+        /// </summary>
+        /// <param name="notification">The notification to deliver.</param>
+        public PendingNotification ScheduleNotification(IGameNotification notification)
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            if (notification == null)
+            {
+                return null;
+            }
+
+            // If we queue, don't schedule immediately.
+            // Also immediately schedule non-time based deliveries (for iOS)
+            if ((Mode & OperatingMode.Queue) != OperatingMode.Queue || notification.DeliveryTime == null)
+            {
+                _platform?.ScheduleNotification(notification);
+            }
+            else if (!notification.Id.HasValue)
+            {
+                // Generate an ID for items that don't have one (just so they can be identified later)
+                notification.Id = Math.Abs(DateTime.Now.ToString("yyMMddHHmmssffffff").GetHashCode());
+            }
+
+            // Register pending notification
+            var result = new PendingNotification(notification);
+            PendingNotifications.Add(result);
+
+            return result;
+        }
+
+        /// <summary>
+        /// Cancels a scheduled notification.
+        /// </summary>
+        /// <param name="notificationId">The ID of the notification to cancel.</param>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has not been called.</exception>
+        public void CancelNotification(int notificationId)
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            if (_platform == null)
+            {
+                return;
+            }
+
+            _platform.CancelNotification(notificationId);
+
+            // Remove the cancelled notification from scheduled list
+            var index = PendingNotifications.FindIndex(scheduledNotification =>
+                scheduledNotification.Notification.Id == notificationId);
+
+            if (index >= 0)
+            {
+                PendingNotifications.RemoveAt(index);
+            }
+        }
+
+        /// <summary>
+        /// Cancels all scheduled notifications.
+        /// </summary>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has not been called.</exception>
+        public void CancelAllNotifications()
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            if (_platform == null)
+            {
+                return;
+            }
+
+            _platform.CancelAllScheduledNotifications();
+
+            PendingNotifications.Clear();
+        }
+
+        /// <summary>
+        /// Dismisses a displayed notification.
+        /// </summary>
+        /// <param name="notificationId">The ID of the notification to dismiss.</param>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has not been called.</exception>
+        public void DismissNotification(int notificationId)
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            _platform?.DismissNotification(notificationId);
+        }
+
+        /// <summary>
+        /// Dismisses all displayed notifications.
+        /// </summary>
+        /// <exception cref="InvalidOperationException"><see cref="Initialize"/> has not been called.</exception>
+        public void DismissAllNotifications()
+        {
+            if (!Initialized)
+            {
+                throw new InvalidOperationException("Must call Initialize() first.");
+            }
+
+            _platform?.DismissAllDisplayedNotifications();
+        }
+
+        /// <summary>
+        /// Event fired by <see cref="_platform"/> when a notification is received.
+        /// </summary>
+        private void OnNotificationReceived(IGameNotification deliveredNotification)
+        {
+            // Ignore for background messages (this happens on Android sometimes)
+            if (!_inForeground)
+            {
+                return;
+            }
+
+            // Find in pending list
+            int deliveredIndex = PendingNotifications.FindIndex(
+                scheduledNotification => scheduledNotification.Notification.Id == deliveredNotification.Id);
+            
+            if (deliveredIndex >= 0)
+            {
+                OnLocalNotificationDelivered?.Invoke(PendingNotifications[deliveredIndex]);
+                PendingNotifications.RemoveAt(deliveredIndex);
+            }
+        }
+
+        // Clear foreground notifications and reschedule stuff from a file
+        private void OnForegrounding()
+        {
+            PendingNotifications.Clear();
+            _platform.OnForeground();
+
+            // Deserialize saved items
+            var notifications = JsonUtility.FromJson<List<SerializableNotification>>(PlayerPrefs.GetString("notifications"));
+
+            // Foregrounding
+            if ((Mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding)
+            {
+                // Clear on foregrounding
+                _platform.CancelAllScheduledNotifications();
+
+                // Only reschedule in reschedule mode, and if we loaded any items
+                if (notifications == null || (Mode & OperatingMode.RescheduleAfterClearing) != OperatingMode.RescheduleAfterClearing)
+                {
+                    return;
+                }
+
+                // Reschedule notifications from deserialization
+                foreach (var savedNotification in notifications)
+                {
+                    var deliveryTime = savedNotification.GetDeliveryTime();
+                    if (deliveryTime.HasValue && deliveryTime.Value > DateTime.Now)
+                    {
+                        var pendingNotification = ScheduleNotification(savedNotification.AsGameNotification(_platform));
+                        
+                        pendingNotification.Reschedule = true;
+                    }
+                }
+            }
+            else
+            {
+                // Just create PendingNotification wrappers for all deserialized items.
+                // We're not rescheduling them because they were not cleared
+                if (notifications == null)
+                {
+                    return;
+                }
+
+                foreach (var savedNotification in notifications)
+                {
+                    var deliveryTime = savedNotification.GetDeliveryTime();
+                    if (deliveryTime.HasValue && deliveryTime.Value > DateTime.Now)
+                    {
+                        PendingNotifications.Add(new PendingNotification(savedNotification.AsGameNotification(_platform)));
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta
new file mode 100644
index 0000000..2e5bacd
--- /dev/null
+++ b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7b0d45bfd9ea64de290a68b934db7ad7
\ No newline at end of file
diff --git a/Runtime/Notifications/IGameNotification.cs b/Runtime/Notifications/IGameNotification.cs
new file mode 100644
index 0000000..07be04c
--- /dev/null
+++ b/Runtime/Notifications/IGameNotification.cs
@@ -0,0 +1,83 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Represents a notification that will be delivered for this application.
+    /// </summary>
+    public interface IGameNotification
+    {
+        /// <summary>
+        /// Gets or sets a unique identifier for this notification.
+        /// </summary>
+        /// <remarks>
+        /// <para>
+        /// If null, will be generated automatically once the notification is delivered, and then
+        /// can be retrieved afterwards.
+        /// </para>
+        /// <para>On some platforms, this might be converted to a string identifier internally.</para>
+        /// </remarks>
+        /// <value>A unique integer identifier for this notification, or null (on some platforms) if not explicitly set.</value>
+        int? Id { get; set; }
+
+        /// <summary>
+        /// Gets or sets the notification's title.
+        /// </summary>
+        /// <value>The title message for the notification.</value>
+        string Title { get; set; }
+
+        /// <summary>
+        /// Gets or sets the body text of the notification.
+        /// </summary>
+        /// <value>The body message for the notification.</value>
+        string Body { get; set; }
+
+        /// <summary>
+        /// Gets or sets a subtitle for the notification.
+        /// </summary>
+        /// <value>The subtitle message for the notification.</value>
+        string Subtitle { get; set; }
+
+        /// <summary>
+        /// Gets or sets channel to which this notification belongs.
+        /// </summary>
+        /// <value>A platform specific string identifier for the notification's channel.</value>
+        string Channel { get; set; }
+
+        /// <summary>
+        /// Gets or sets the badge number for this notification. No badge number will be shown if null.
+        /// </summary>
+        /// <value>The number displayed on the app badge.</value>
+        int? BadgeNumber { get; set; }
+
+        /// <summary>
+        /// Gets or sets if this notification will be dismissed automatically when the user taps it.
+        /// Only available on Android.
+        /// </summary>
+        bool ShouldAutoCancel { get; set; }
+
+        /// <summary>
+        /// Gets or sets time to deliver the notification.
+        /// </summary>
+        /// <value>The time of delivery in local time.</value>
+        DateTime? DeliveryTime { get; set; }
+
+        /// <summary>
+        /// Gets whether this notification has been scheduled.
+        /// </summary>
+        /// <value>True if the notification has been scheduled with the underlying operating system.</value>
+        bool Scheduled { get; }
+
+        /// <summary>
+        /// Notification small icon.
+        /// </summary>
+        string SmallIcon { get; set; }
+
+        /// <summary>
+        /// Notification large icon.
+        /// </summary>
+        string LargeIcon { get; set; }
+    }
+}
diff --git a/Runtime/Notifications/IGameNotification.cs.meta b/Runtime/Notifications/IGameNotification.cs.meta
new file mode 100644
index 0000000..03ca995
--- /dev/null
+++ b/Runtime/Notifications/IGameNotification.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8fd2ac3834f914528ba469316784950b
\ No newline at end of file
diff --git a/Runtime/Notifications/Internal.meta b/Runtime/Notifications/Internal.meta
new file mode 100644
index 0000000..244a7dc
--- /dev/null
+++ b/Runtime/Notifications/Internal.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f1a61a43141b448f2bfd863bbef3757a
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs b/Runtime/Notifications/Internal/EditorGameNotification.cs
new file mode 100644
index 0000000..8d3bc80
--- /dev/null
+++ b/Runtime/Notifications/Internal/EditorGameNotification.cs
@@ -0,0 +1,35 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+	/// <summary>
+	/// Editor specific implementation of <see cref="IGameNotification"/>.
+	/// </summary>
+	internal class EditorGameNotification : IGameNotification
+	{
+		/// <inheritdoc />
+		public int? Id { get; set; }
+		/// <inheritdoc />
+		public string Title { get; set; }
+		/// <inheritdoc />
+		public string Body { get; set; }
+		/// <inheritdoc />
+		public string Subtitle { get; set; }
+		/// <inheritdoc />
+		public string Channel { get; set; }
+		/// <inheritdoc />
+		public int? BadgeNumber { get; set; }
+		/// <inheritdoc />
+		public bool ShouldAutoCancel { get; set; }
+		/// <inheritdoc />
+		public DateTime? DeliveryTime { get; set; }
+		/// <inheritdoc />
+		public bool Scheduled { get; }
+		/// <inheritdoc />
+		public string SmallIcon { get; set; }
+		/// <inheritdoc />
+		public string LargeIcon { get; set; }
+	}
+}
\ No newline at end of file
diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs.meta b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta
new file mode 100644
index 0000000..71736f8
--- /dev/null
+++ b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta
@@ -0,0 +1,3 @@
+﻿fileFormatVersion: 2
+guid: a052ebf6dc5694df6997f5146b7c227c
+timeCreated: 1599154756
\ No newline at end of file
diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs
new file mode 100644
index 0000000..345fe4d
--- /dev/null
+++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs
@@ -0,0 +1,85 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Any type that handles notifications for a specific game platform
+    /// </summary>
+    internal interface IGameNotificationsPlatform
+    {
+        /// <summary>
+        /// Fired when a notification is received.
+        /// </summary>
+        event Action<IGameNotification> NotificationReceived;
+
+        /// <summary>
+        /// Create a new instance of a <see cref="IGameNotification"/> for this platform.
+        /// </summary>
+        /// <returns>A new platform-appropriate notification object.</returns>
+        IGameNotification CreateNotification();
+
+        /// <summary>
+        /// Schedules a notification to be delivered.
+        /// </summary>
+        /// <param name="gameNotification">The notification to deliver.</param>
+        /// <exception cref="ArgumentNullException"><paramref name="gameNotification"/> is null.</exception>
+        /// <exception cref="InvalidOperationException"><paramref name="gameNotification"/> isn't of the correct type.</exception>
+        void ScheduleNotification(IGameNotification gameNotification);
+
+        /// <summary>
+        /// Cancels a scheduled notification.
+        /// </summary>
+        /// <param name="notificationId">The ID of a previously scheduled notification.</param>
+        void CancelNotification(int notificationId);
+
+        /// <summary>
+        /// Dismiss a displayed notification.
+        /// </summary>
+        /// <param name="notificationId">The ID of a previously scheduled notification that is being displayed to the user.</param>
+        void DismissNotification(int notificationId);
+
+        /// <summary>
+        /// Cancels all scheduled notifications.
+        /// </summary>
+        void CancelAllScheduledNotifications();
+
+        /// <summary>
+        /// Dismisses all displayed notifications.
+        /// </summary>
+        void DismissAllDisplayedNotifications();
+
+        /// <summary>
+        /// Performs any initialization or processing necessary on foregrounding the application.
+        /// </summary>
+        void OnForeground();
+
+        /// <summary>
+        /// Performs any processing necessary on backgrounding or closing the application.
+        /// </summary>
+        void OnBackground();
+    }
+
+    /// <summary>
+    /// Any type that handles notifications for a specific game platform.
+    /// </summary>
+    /// <remarks>Has a concrete notification type</remarks>
+    /// <typeparam name="TNotificationType">The type of notification returned by this platform.</typeparam>
+    internal interface IGameNotificationsPlatform<TNotificationType> : IGameNotificationsPlatform
+        where TNotificationType : IGameNotification
+    {
+        /// <summary>
+        /// Create an instance of <typeparamref name="TNotificationType"/>.
+        /// </summary>
+        /// <returns>A new platform-appropriate notification object.</returns>
+        new TNotificationType CreateNotification();
+
+        /// <summary>
+        /// Schedule a notification to be delivered.
+        /// </summary>
+        /// <param name="notification">The notification to deliver.</param>
+        /// <exception cref="ArgumentNullException"><paramref name="notification"/> is null.</exception>
+        void ScheduleNotification(TNotificationType notification);
+    }
+}
diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta
new file mode 100644
index 0000000..fd4dda9
--- /dev/null
+++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3b3bb06dceeff43eeb1e02497cbca219
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs b/Runtime/Notifications/Internal/SerializableNotification.cs
new file mode 100644
index 0000000..d1937a6
--- /dev/null
+++ b/Runtime/Notifications/Internal/SerializableNotification.cs
@@ -0,0 +1,78 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+	/// <summary>
+	/// Notification to serialize/deserialize to disk when the game goes foreground
+	/// </summary>
+	[Serializable]
+	internal struct SerializableNotification
+	{
+		public bool HasId;
+		public int Id;
+		public string Title;
+		public string Body;
+		public string Subtitle;
+		public string Channel;
+		public bool HasBadgeNumber;
+		public int BadgeNumber;
+		public bool HasDeliveryTime;
+		public long DeliveryTimeBinary;
+
+		internal DateTime? GetDeliveryTime() =>
+			HasDeliveryTime ? DateTime.FromBinary(DeliveryTimeBinary) : (DateTime?)null;
+	}
+
+	/// <summary>
+	/// Converter serialization classes
+	/// </summary>
+	internal static class SerializableNotificationConverter
+	{
+		public static IGameNotification AsGameNotification(this SerializableNotification serializableNotification,
+			IGameNotificationsPlatform platform)
+		{
+			var notification = platform.CreateNotification();
+
+			notification.Id = ReadNullableInt(serializableNotification.HasId, serializableNotification.Id);
+			notification.Title = serializableNotification.Title;
+			notification.Body = serializableNotification.Body;
+			notification.Subtitle = serializableNotification.Subtitle;
+			notification.Channel = serializableNotification.Channel;
+			notification.BadgeNumber = ReadNullableInt(
+				serializableNotification.HasBadgeNumber,
+				serializableNotification.BadgeNumber);
+			notification.DeliveryTime = ReadNullableDateTime(
+				serializableNotification.HasDeliveryTime,
+				serializableNotification.DeliveryTimeBinary);
+
+			return notification;
+		}
+
+		public static SerializableNotification AsSerializableNotification(this PendingNotification pendingNotification)
+		{
+			var source = pendingNotification.Notification;
+			var deliveryTime = source.DeliveryTime;
+
+			return new SerializableNotification
+			{
+				HasId = source.Id.HasValue,
+				Id = source.Id ?? 0,
+				Title = source.Title,
+				Body = source.Body,
+				Subtitle = source.Subtitle,
+				Channel = source.Channel,
+				HasBadgeNumber = source.BadgeNumber.HasValue,
+				BadgeNumber = source.BadgeNumber ?? 0,
+				HasDeliveryTime = deliveryTime.HasValue,
+				DeliveryTimeBinary = deliveryTime?.ToBinary() ?? 0L,
+			};
+		}
+
+		private static int? ReadNullableInt(bool hasValue, int value) => hasValue ? value : (int?)null;
+
+		private static DateTime? ReadNullableDateTime(bool hasValue, long binary) =>
+			hasValue ? DateTime.FromBinary(binary) : (DateTime?)null;
+	}
+}
diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs.meta b/Runtime/Notifications/Internal/SerializableNotification.cs.meta
new file mode 100644
index 0000000..32b179e
--- /dev/null
+++ b/Runtime/Notifications/Internal/SerializableNotification.cs.meta
@@ -0,0 +1,3 @@
+﻿fileFormatVersion: 2
+guid: 9feafbfcc431549358ef3bc9074a3f0c
+timeCreated: 1596468382
\ No newline at end of file
diff --git a/Runtime/Notifications/MobileNotificationService.cs b/Runtime/Notifications/MobileNotificationService.cs
new file mode 100644
index 0000000..a32f203
--- /dev/null
+++ b/Runtime/Notifications/MobileNotificationService.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+	/// <summary>
+	/// This service allows to schedule and handle notifications on the current platform
+	/// </summary>
+	public interface INotificationService
+	{
+		/// <summary>
+		/// Event fired when a scheduled local notification is delivered while the app is in the foreground.
+		/// </summary>
+		event Action<PendingNotification> OnLocalNotificationDeliveredEvent;
+
+		/// <summary>
+		/// Event fired when a queued local notification is cancelled because the application is in the foreground
+		/// when it was meant to be displayed.
+		/// </summary>
+		/// <seealso cref="OperatingMode.Queue"/>
+		event Action<PendingNotification> OnLocalNotificationExpiredEvent;
+
+		/// <summary>
+		/// The queueing / delivery <see cref="OperatingMode"/> for the service. Defaults to
+		/// <see cref="OperatingMode.NoQueue"/>. Settable at runtime; the new value takes effect on the
+		/// next <see cref="ScheduleNotification"/> call and the next foreground/background transition.
+		/// </summary>
+		OperatingMode Mode { get; set; }
+
+		/// <summary>
+		/// Gets a collection of notifications that are scheduled or queued.
+		/// </summary>
+		IReadOnlyList<PendingNotification> PendingNotifications { get; }
+		
+		/// <summary>
+		/// Create a new instance of a <see cref="IGameNotification"/> for this platform.
+		/// </summary>
+		/// <returns>A new platform-appropriate notification object.</returns>
+		IGameNotification CreateNotification();
+
+		/// <summary>
+		/// Schedules a notification to be delivered.
+		/// </summary>
+		/// <param name="gameNotification">The notification to deliver.</param>
+		/// <exception cref="ArgumentNullException"><paramref name="gameNotification"/> is null.</exception>
+		/// <exception cref="InvalidOperationException"><paramref name="gameNotification"/> isn't of the correct type.</exception>
+		PendingNotification ScheduleNotification(IGameNotification gameNotification);
+
+		/// <summary>
+		/// Cancels a scheduled notification.
+		/// </summary>
+		/// <param name="notificationId">The ID of a previously scheduled notification.</param>
+		void CancelNotification(int notificationId);
+
+		/// <summary>
+		/// Dismiss a displayed notification.
+		/// </summary>
+		/// <param name="notificationId">The ID of a previously scheduled notification that is being displayed to the user.</param>
+		void DismissNotification(int notificationId);
+
+		/// <summary>
+		/// Cancels all scheduled notifications.
+		/// </summary>
+		void CancelAllScheduledNotifications();
+
+		/// <summary>
+		/// Dismisses all displayed notifications.
+		/// </summary>
+		void DismissAllDisplayedNotifications();
+	}
+	
+	/// <inheritdoc />
+	public class MobileNotificationService : INotificationService
+	{
+		private readonly GameNotificationsMonoBehaviour _monoBehaviour;
+		private readonly GameNotificationChannel[] _channels;
+
+		/// <inheritdoc />
+		public event Action<PendingNotification> OnLocalNotificationDeliveredEvent;
+		/// <inheritdoc />
+		public event Action<PendingNotification> OnLocalNotificationExpiredEvent;
+
+		/// <inheritdoc />
+		public IReadOnlyList<PendingNotification> PendingNotifications => _monoBehaviour.PendingNotifications;
+
+		/// <inheritdoc />
+		public OperatingMode Mode
+		{
+			get => _monoBehaviour.Mode;
+			set => _monoBehaviour.Mode = value;
+		}
+
+		/// <summary>The queueing / delivery mode the host MonoBehaviour was configured with.</summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal OperatingMode CurrentMode => _monoBehaviour != null ? _monoBehaviour.Mode : OperatingMode.NoQueue;
+
+		/// <summary>The channels passed to the constructor (Android default-channel-id resolution + Explorer display).</summary>
+		/// <remarks>Editor introspection accessor — not part of the public surface.</remarks>
+		internal IReadOnlyList<GameNotificationChannel> Channels => _channels;
+
+		public MobileNotificationService(params GameNotificationChannel[] channels)
+		{
+			_channels = channels ?? Array.Empty<GameNotificationChannel>();
+			_monoBehaviour = new GameObject("NotificationService").AddComponent<GameNotificationsMonoBehaviour>();
+			_monoBehaviour.OnLocalNotificationDelivered = OnLocalNotificationDeliveredEvent;
+			_monoBehaviour.OnLocalNotificationExpired = OnLocalNotificationExpiredEvent;
+
+			_monoBehaviour.Initialize(channels);
+			UnityEngine.Object.DontDestroyOnLoad(_monoBehaviour);
+		}
+
+		/// <inheritdoc />
+		public IGameNotification CreateNotification()
+		{
+#if UNITY_EDITOR
+			return new EditorGameNotification();
+#else
+			return _monoBehaviour.CreateNotification();
+#endif
+		}
+
+		/// <inheritdoc />
+		public PendingNotification ScheduleNotification(IGameNotification gameNotification)
+		{
+#if UNITY_EDITOR
+			if (!gameNotification.Id.HasValue)
+            {
+                // Generate an ID for items that don't have one (just so they can be identified later)
+                gameNotification.Id = Math.Abs(DateTime.Now.ToString("yyMMddHHmmssffffff").GetHashCode());
+            }
+			return new PendingNotification(gameNotification);
+#else
+			return _monoBehaviour.ScheduleNotification(gameNotification);
+#endif
+		}
+
+		/// <inheritdoc />
+		public void CancelNotification(int notificationId)
+		{
+			_monoBehaviour.CancelNotification(notificationId);
+		}
+
+		/// <inheritdoc />
+		public void DismissNotification(int notificationId)
+		{
+			_monoBehaviour.DismissNotification(notificationId);
+		}
+
+		/// <inheritdoc />
+		public void CancelAllScheduledNotifications()
+		{
+			_monoBehaviour.CancelAllNotifications();
+		}
+
+		/// <inheritdoc />
+		public void DismissAllDisplayedNotifications()
+		{
+			_monoBehaviour.DismissAllNotifications();
+		}
+	}
+}
\ No newline at end of file
diff --git a/Runtime/Notifications/MobileNotificationService.cs.meta b/Runtime/Notifications/MobileNotificationService.cs.meta
new file mode 100644
index 0000000..7b5a805
--- /dev/null
+++ b/Runtime/Notifications/MobileNotificationService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 04122135a6ed94669aa8f08ba2169106
\ No newline at end of file
diff --git a/Runtime/Notifications/NotificationBuilder.cs b/Runtime/Notifications/NotificationBuilder.cs
new file mode 100644
index 0000000..8d43a05
--- /dev/null
+++ b/Runtime/Notifications/NotificationBuilder.cs
@@ -0,0 +1,84 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Notifications
+{
+	/// <summary>
+	/// Fluent builder over <see cref="INotificationService.CreateNotification"/> +
+	/// <see cref="INotificationService.ScheduleNotification"/>. Removes the 6-line property-assignment
+	/// boilerplate from the common case:
+	/// <code>
+	/// service.Schedule()
+	///     .In(TimeSpan.FromHours(24))
+	///     .Title("Daily Reward")
+	///     .Body("Your reward awaits!")
+	///     .Channel("rewards")
+	///     .BadgeIncrement()
+	///     .Send();
+	/// </code>
+	/// </summary>
+	public sealed class NotificationBuilder
+	{
+		private readonly INotificationService _service;
+		private readonly IGameNotification _notification;
+		private bool _badgeIncrement;
+
+		internal NotificationBuilder(INotificationService service)
+		{
+			_service = service ?? throw new ArgumentNullException(nameof(service));
+			_notification = service.CreateNotification();
+		}
+
+		public NotificationBuilder Title(string title) { _notification.Title = title; return this; }
+		public NotificationBuilder Body(string body) { _notification.Body = body; return this; }
+		public NotificationBuilder Subtitle(string subtitle) { _notification.Subtitle = subtitle; return this; }
+		public NotificationBuilder Channel(string channelId) { _notification.Channel = channelId; return this; }
+		public NotificationBuilder Id(int id) { _notification.Id = id; return this; }
+		public NotificationBuilder BadgeNumber(int? badge) { _notification.BadgeNumber = badge; return this; }
+		public NotificationBuilder SmallIcon(string smallIcon) { _notification.SmallIcon = smallIcon; return this; }
+		public NotificationBuilder LargeIcon(string largeIcon) { _notification.LargeIcon = largeIcon; return this; }
+		public NotificationBuilder AutoCancel(bool shouldAutoCancel = true) { _notification.ShouldAutoCancel = shouldAutoCancel; return this; }
+		public NotificationBuilder At(DateTime deliveryTime) { _notification.DeliveryTime = deliveryTime; return this; }
+		public NotificationBuilder In(TimeSpan delay) { _notification.DeliveryTime = DateTime.Now + delay; return this; }
+
+		/// <summary>
+		/// Marks the notification for badge-increment behaviour. The underlying
+		/// <see cref="GameNotificationsMonoBehaviour"/> auto-increments badge numbers when none of the
+		/// pending notifications have one set; calling this leaves <see cref="IGameNotification.BadgeNumber"/>
+		/// unset so the auto-increment path engages.
+		/// </summary>
+		public NotificationBuilder BadgeIncrement()
+		{
+			_badgeIncrement = true;
+			_notification.BadgeNumber = null;
+			return this;
+		}
+
+		/// <summary>
+		/// Schedules the built notification and returns the resulting <see cref="PendingNotification"/>.
+		/// </summary>
+		public PendingNotification Send()
+		{
+			// _badgeIncrement is reserved for future behaviour flips; the auto-increment path on the
+			// host MonoBehaviour fires when BadgeNumber == null on every queued entry, so simply leaving
+			// it null (the default once BadgeIncrement is called) is sufficient today.
+			_ = _badgeIncrement;
+			return _service.ScheduleNotification(_notification);
+		}
+	}
+
+	/// <summary>
+	/// Extension methods exposing the fluent builder on <see cref="INotificationService"/>.
+	/// </summary>
+	public static class NotificationServiceExtensions
+	{
+		/// <summary>
+		/// Returns a new <see cref="NotificationBuilder"/> backed by this service. Each call constructs
+		/// a fresh notification via <see cref="INotificationService.CreateNotification"/>.
+		/// </summary>
+		public static NotificationBuilder Schedule(this INotificationService service)
+		{
+			return new NotificationBuilder(service);
+		}
+	}
+}
diff --git a/Runtime/Notifications/NotificationBuilder.cs.meta b/Runtime/Notifications/NotificationBuilder.cs.meta
new file mode 100644
index 0000000..02b0105
--- /dev/null
+++ b/Runtime/Notifications/NotificationBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 631ece187646d48be8c8e13328f98d3e
\ No newline at end of file
diff --git a/Runtime/Notifications/PendingNotification.cs b/Runtime/Notifications/PendingNotification.cs
new file mode 100644
index 0000000..37d8f48
--- /dev/null
+++ b/Runtime/Notifications/PendingNotification.cs
@@ -0,0 +1,41 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// Represents a notification that was scheduled with <see cref="GameNotificationsMonoBehaviour.ScheduleNotification"/>.
+    /// </summary>
+    public class PendingNotification
+    {
+        /// <summary>
+        /// Whether to reschedule this event if it hasn't displayed once the app is foregrounded again.
+        /// </summary>
+        /// <remarks>
+        /// <para>
+        /// Only valid if the <see cref="GameNotificationsMonoBehaviour"/>'s <see cref="GameNotificationsMonoBehaviour.Mode"/>
+        /// flag is set to <see cref="OperatingMode.RescheduleAfterClearing"/>.
+        /// </para>
+        /// <para>
+        /// Will not function for any notifications that are using a delivery scheduling method that isn't time
+        /// based, such as iOS location notifications.
+        /// </para>
+        /// </remarks>
+        public bool Reschedule;
+
+        /// <summary>
+        /// The scheduled notification.
+        /// </summary>
+        public readonly IGameNotification Notification;
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="PendingNotification"/> from a <see cref="IGameNotification"/>.
+        /// </summary>
+        /// <param name="notification">The notification to create from.</param>
+        public PendingNotification(IGameNotification notification)
+        {
+            Notification = notification ?? throw new ArgumentNullException(nameof(notification));
+        }
+    }
+}
diff --git a/Runtime/Notifications/PendingNotification.cs.meta b/Runtime/Notifications/PendingNotification.cs.meta
new file mode 100644
index 0000000..b9b4a84
--- /dev/null
+++ b/Runtime/Notifications/PendingNotification.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d2ae73b3fa5e343cbbfbc118ba2940f2
\ No newline at end of file
diff --git a/Runtime/Notifications/iOS.meta b/Runtime/Notifications/iOS.meta
new file mode 100644
index 0000000..5c2990a
--- /dev/null
+++ b/Runtime/Notifications/iOS.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4db05989dfd8343c4a92c1cd81843ec4
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs b/Runtime/Notifications/iOS/iOSGameNotification.cs
new file mode 100644
index 0000000..15231e2
--- /dev/null
+++ b/Runtime/Notifications/iOS/iOSGameNotification.cs
@@ -0,0 +1,182 @@
+#if UNITY_IOS
+using System;
+using Unity.Notifications.iOS;
+using UnityEngine;
+using UnityEngine.Assertions;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// iOS implementation of <see cref="IGameNotification"/>.
+    /// </summary>
+    public class iOSGameNotification : IGameNotification
+    {
+        private readonly iOSNotification internalNotification;
+
+        /// <summary>
+        /// Gets the internal notification object used by the mobile notifications system.
+        /// </summary>
+        public iOSNotification InternalNotification => internalNotification;
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// Internally stored as a string. Gets parsed to an integer when retrieving.
+        /// </remarks>
+        /// <value>The identifier as an integer, or null if the identifier couldn't be parsed as a number.</value>
+        public int? Id
+        {
+            get
+            {
+                if (!int.TryParse(internalNotification.Identifier, out int value))
+                {
+                    Debug.LogWarning("Internal iOS notification's identifier isn't a number.");
+                    return null;
+                }
+
+                return value;
+            }
+            set
+            {
+                if (value == null)
+                {
+                    throw new ArgumentNullException(nameof(value));
+                }
+
+                internalNotification.Identifier = value.Value.ToString();
+            }
+        }
+
+        /// <inheritdoc />
+        public string Title { get => internalNotification.Title; set => internalNotification.Title = value; }
+
+        /// <inheritdoc />
+        public string Body { get => internalNotification.Body; set => internalNotification.Body = value; }
+
+        /// <inheritdoc />
+        public string Subtitle { get => internalNotification.Subtitle; set => internalNotification.Subtitle = value; }
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// On iOS, this represents the notification's Category Identifier.
+        /// </remarks>
+        /// <value>The value of <see cref="CategoryIdentifier"/>.</value>
+        public string Channel { get => CategoryIdentifier; set => CategoryIdentifier = value; }
+
+        /// <inheritdoc />
+        public int? BadgeNumber
+        {
+            get => internalNotification.Badge != -1 ? internalNotification.Badge : (int?)null;
+            set => internalNotification.Badge = value ?? -1;
+        }
+
+        /// <inheritdoc />
+        public bool ShouldAutoCancel { get; set; }
+
+        /// <inheritdoc />
+        public bool Scheduled { get; private set; }
+
+        /// <inheritdoc />
+        /// <remarks>
+        /// <para>On iOS, setting this causes the notification to be delivered on a calendar time.</para>
+        /// <para>If it has previously been manually set to a different type of trigger, or has not been set before,
+        /// this returns null.</para>
+        /// <para>The millisecond component of the provided DateTime is ignored.</para>
+        /// </remarks>
+        /// <value>A <see cref="DateTime"/> representing the delivery time of this message, or null if
+        /// not set or the trigger isn't a <see cref="iOSNotificationCalendarTrigger"/>.</value>
+        public DateTime? DeliveryTime
+        {
+            get
+            {
+                if (!(internalNotification.Trigger is iOSNotificationCalendarTrigger calendarTrigger))
+                {
+                    return null;
+                }
+
+                DateTime now = DateTime.Now;
+                var result = new DateTime
+                    (
+                    calendarTrigger.Year ?? now.Year,
+                    calendarTrigger.Month ?? now.Month,
+                    calendarTrigger.Day ?? now.Day,
+                    calendarTrigger.Hour ?? now.Hour,
+                    calendarTrigger.Minute ?? now.Minute,
+                    calendarTrigger.Second ?? now.Second,
+                    DateTimeKind.Local
+                    );
+
+                return result;
+            }
+            set
+            {
+                if (!value.HasValue)
+                {
+                    return;
+                }
+
+                DateTime date = value.Value.ToLocalTime();
+
+                internalNotification.Trigger = new iOSNotificationCalendarTrigger
+                {
+                    Year = date.Year,
+                    Month = date.Month,
+                    Day = date.Day,
+                    Hour = date.Hour,
+                    Minute = date.Minute,
+                    Second = date.Second
+                };
+            }
+        }
+
+        /// <summary>
+        /// The category identifier for this notification.
+        /// </summary>
+        public string CategoryIdentifier
+        {
+            get => internalNotification.CategoryIdentifier;
+            set => internalNotification.CategoryIdentifier = value;
+        }
+
+        /// <summary>
+        /// Does nothing on iOS.
+        /// </summary>
+        public string SmallIcon { get => null; set {} }
+
+        /// <summary>
+        /// Does nothing on iOS.
+        /// </summary>
+        public string LargeIcon { get => null; set {} }
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="iOSGameNotification"/>.
+        /// </summary>
+        public iOSGameNotification()
+        {
+            internalNotification = new iOSNotification
+            {
+                ShowInForeground = true // Deliver in foreground by default
+            };
+        }
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="iOSGameNotification"/> from a delivered notification.
+        /// </summary>
+        /// <param name="internalNotification">The delivered notification.</param>
+        internal iOSGameNotification(iOSNotification internalNotification)
+        {
+            this.internalNotification = internalNotification;
+        }
+
+        /// <summary>
+        /// Mark this notifications scheduled flag.
+        /// </summary>
+        internal void OnScheduled()
+        {
+            Assert.IsFalse(Scheduled);
+            Scheduled = true;
+        }
+    }
+}
+#endif
diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs.meta b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta
new file mode 100644
index 0000000..da77613
--- /dev/null
+++ b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8e8a948020e47491bba0b2fa0317f370
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs
new file mode 100644
index 0000000..df5dbb3
--- /dev/null
+++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs
@@ -0,0 +1,126 @@
+#if UNITY_IOS
+using System;
+using Unity.Notifications.iOS;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLovers.MobileServices.Notifications
+{
+    /// <summary>
+    /// iOS implementation of <see cref="IGameNotificationsPlatform"/>.
+    /// </summary>
+    internal class iOSNotificationsPlatform : IGameNotificationsPlatform<iOSGameNotification>, IDisposable
+    {
+        /// <inheritdoc />
+        public event Action<IGameNotification> NotificationReceived;
+
+        /// <summary>
+        /// Instantiate a new instance of <see cref="iOSNotificationsPlatform"/>.
+        /// </summary>
+        public iOSNotificationsPlatform()
+        {
+            iOSNotificationCenter.OnNotificationReceived += OnLocalNotificationReceived;
+        }
+
+        /// <inheritdoc />
+        public void ScheduleNotification(IGameNotification gameNotification)
+        {
+            if (gameNotification == null)
+            {
+                throw new ArgumentNullException(nameof(gameNotification));
+            }
+
+            if (!(gameNotification is iOSGameNotification notification))
+            {
+                throw new InvalidOperationException(
+                    "Notification provided to ScheduleNotification isn't an iOSGameNotification.");
+            }
+
+            ScheduleNotification(notification);
+        }
+
+        /// <inheritdoc />
+        public void ScheduleNotification(iOSGameNotification notification)
+        {
+            if (notification == null)
+            {
+                throw new ArgumentNullException(nameof(notification));
+            }
+
+            iOSNotificationCenter.ScheduleNotification(notification.InternalNotification);
+            notification.OnScheduled();
+        }
+
+        /// <inheritdoc />
+        /// <summary>
+        /// Create a new <see cref="T:NotificationSamples.Android.AndroidNotification" />.
+        /// </summary>
+        IGameNotification IGameNotificationsPlatform.CreateNotification()
+        {
+            return CreateNotification();
+        }
+
+        /// <inheritdoc />
+        /// <summary>
+        /// Create a new <see cref="T:NotificationSamples.Android.AndroidNotification" />.
+        /// </summary>
+        public iOSGameNotification CreateNotification()
+        {
+            return new iOSGameNotification();
+        }
+
+        /// <inheritdoc />
+        public void CancelNotification(int notificationId)
+        {
+            iOSNotificationCenter.RemoveScheduledNotification(notificationId.ToString());
+        }
+
+        /// <inheritdoc />
+        public void DismissNotification(int notificationId)
+        {
+            iOSNotificationCenter.RemoveDeliveredNotification(notificationId.ToString());
+        }
+
+        /// <inheritdoc />
+        public void CancelAllScheduledNotifications()
+        {
+            iOSNotificationCenter.RemoveAllScheduledNotifications();
+        }
+
+        /// <inheritdoc />
+        public void DismissAllDisplayedNotifications()
+        {
+            iOSNotificationCenter.RemoveAllDeliveredNotifications();
+        }
+
+        /// <summary>
+        /// Clears badge count.
+        /// </summary>
+        public void OnForeground()
+        {
+            iOSNotificationCenter.ApplicationBadge = 0;
+        }
+
+        /// <summary>
+        /// Does nothing on iOS.
+        /// </summary>
+        public void OnBackground() {}
+
+        /// <summary>
+        /// Unregister delegates.
+        /// </summary>
+        public void Dispose()
+        {
+            iOSNotificationCenter.OnNotificationReceived -= OnLocalNotificationReceived;
+        }
+
+        // Event handler for receiving local notifications.
+        private void OnLocalNotificationReceived(iOSNotification notification)
+        {
+            // Create a new AndroidGameNotification out of the delivered notification, but only
+            // if the event is registered
+            NotificationReceived?.Invoke(new iOSGameNotification(notification));
+        }
+    }
+}
+#endif
diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta
new file mode 100644
index 0000000..f2ff17a
--- /dev/null
+++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 49a427f0b194b41b3a9623b6190f3a65
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Runtime/Plugins/iOS/NativeUi.m b/Runtime/Plugins/iOS/NativeUi.m
deleted file mode 100644
index 4882bfa..0000000
--- a/Runtime/Plugins/iOS/NativeUi.m
+++ /dev/null
@@ -1,40 +0,0 @@
-extern UIViewController *UnityGetGLViewController();
-
-NSString *ToNSString(char* string) {
-    return [NSString stringWithUTF8String:string];
-}
-
-typedef void (*AlertButtonCallback)(const char * str);
-
-void AlertMessage (bool isSheet, char* title, char* message, char* buttonsText[], int buttonsStyle[], int buttonsLength, AlertButtonCallback buttonCallback) 
-{
-    UIAlertControllerStyle style = isSheet ? UIAlertControllerStyleActionSheet : UIAlertControllerStyleAlert;
-    UIAlertController *alert = [UIAlertController alertControllerWithTitle:ToNSString(title) message:ToNSString(message) preferredStyle:style];
-
-    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) 
-        {
-            buttonCallback((char*)[buttonText UTF8String]);
-        }];
-        [alert addAction:button];
-    }
-    
-    dispatch_async(dispatch_get_main_queue(), ^{
-        [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil];
-    });
-}
-
-void ToastMessage (char* message, BOOL isLongDuration) 
-{
-    float duration = isLongDuration ? 3.5 : 2;
-    UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:ToNSString(message) preferredStyle:UIAlertControllerStyleAlert];
-    
-    [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil];
-    
-    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, duration * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
-        [alert dismissViewControllerAnimated:YES completion:nil];
-    });
-}
diff --git a/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs b/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs
new file mode 100644
index 0000000..3b5f195
--- /dev/null
+++ b/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs
@@ -0,0 +1,168 @@
+using System;
+using GameLovers.MobileServices.Device;
+using UnityEngine;
+using UnityEngine.UI;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Samples.DeepLinkRouter
+{
+	/// <summary>
+	/// <see cref="IDeepLinkRouter"/> pattern sample with cold-start replay demonstration. See the
+	/// per-sample <c>README.md</c>.
+	/// </summary>
+	public sealed class DeepLinkRouterUI : MonoBehaviour
+	{
+		private DeepLinkService _deepLink;
+		private GameLovers.MobileServices.Device.DeepLinkRouter _router;
+		private Text _log;
+		private Text _coldStartLabel;
+
+		private void Awake()
+		{
+			_deepLink = new DeepLinkService();
+			_router = new GameLovers.MobileServices.Device.DeepLinkRouter(_deepLink);
+
+			_router.MapRoute("/promo/:id", (uri, p) =>
+				Log($"[promo] id={p["id"]} (full: {uri})"));
+			_router.MapRoute("/profile/:userId", (uri, p) =>
+				Log($"[profile] userId={p["userId"]} (full: {uri})"));
+			_router.MapRoute("/settings", (uri, p) =>
+				Log($"[settings] (full: {uri})"));
+		}
+
+		private void Start()
+		{
+			BuildUi();
+			// Cold-start link replay path — if the OS handed the app a launch URL,
+			// the DeepLinkService will replay it to the first subscriber. The router IS the
+			// first subscriber (constructed in Awake), so the replay automatically dispatches.
+			if (_deepLink.PendingColdStartLink != null)
+			{
+				Log($"Pending cold-start link queued: {_deepLink.PendingColdStartLink}");
+			}
+		}
+
+		private void OnDestroy()
+		{
+			_router?.Dispose();
+			_deepLink?.Dispose();
+		}
+
+		private void Update()
+		{
+			if (_coldStartLabel == null) return;
+			_coldStartLabel.text = _deepLink.PendingColdStartLink != null
+				? $"Cold-start: {_deepLink.PendingColdStartLink}"
+				: "Cold-start: (none — link was already consumed or absent)";
+		}
+
+		private void BuildUi()
+		{
+			var canvasGo = new GameObject("Canvas");
+			var canvas = canvasGo.AddComponent<Canvas>();
+			canvas.renderMode = RenderMode.ScreenSpaceOverlay;
+			canvasGo.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
+			canvasGo.AddComponent<GraphicRaycaster>();
+
+			var layoutGo = new GameObject("Layout", typeof(RectTransform), typeof(VerticalLayoutGroup));
+			layoutGo.transform.SetParent(canvas.transform, false);
+			var rt = (RectTransform)layoutGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = new Vector2(16, 16);
+			rt.offsetMax = new Vector2(-16, -16);
+			var v = layoutGo.GetComponent<VerticalLayoutGroup>();
+			v.spacing = 8;
+			v.childForceExpandHeight = false;
+			v.childForceExpandWidth = true;
+
+			AddHeader(layoutGo.transform, "Deep Link Router");
+			AddLabel(layoutGo.transform,
+				"Registered routes: /promo/:id  /profile/:userId  /settings");
+			_coldStartLabel = AddLabel(layoutGo.transform, "Cold-start: …");
+
+			AddSectionHeader(layoutGo.transform, "Try without launching from the OS");
+			AddButton(layoutGo.transform, "Dispatch myapp://promo/spring2026", () =>
+				_router.TryDispatch(new Uri("myapp://promo/spring2026")));
+			AddButton(layoutGo.transform, "Dispatch myapp://profile/abc123", () =>
+				_router.TryDispatch(new Uri("myapp://profile/abc123")));
+			AddButton(layoutGo.transform, "Dispatch myapp://settings", () =>
+				_router.TryDispatch(new Uri("myapp://settings")));
+			AddButton(layoutGo.transform, "Dispatch unmatched URI", () =>
+			{
+				var ok = _router.TryDispatch(new Uri("myapp://unknown/path"));
+				Log($"Unmatched dispatch returned: {ok}");
+			});
+
+			_log = AddLabel(layoutGo.transform, "Log:");
+		}
+
+		private void Log(string message)
+		{
+			Debug.Log($"[DeepLinkRouter] {message}");
+			if (_log == null) return;
+			var lines = _log.text?.Split('\n') ?? Array.Empty<string>();
+			var keep = Math.Max(0, lines.Length - 8);
+			var sb = new System.Text.StringBuilder();
+			sb.AppendLine("Log:");
+			for (var i = keep; i < lines.Length; i++) sb.AppendLine(lines[i]);
+			sb.AppendLine(message);
+			_log.text = sb.ToString();
+		}
+
+		// ---- UI helpers ----
+		private static void AddHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 22;
+			t.fontStyle = FontStyle.Bold;
+		}
+
+		private static void AddSectionHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 16;
+			t.fontStyle = FontStyle.Bold;
+			t.color = new Color(0.8f, 0.9f, 1f);
+		}
+
+		private static Text AddLabel(Transform parent, string text)
+		{
+			var go = new GameObject("Label", typeof(Text));
+			go.transform.SetParent(parent, false);
+			var t = go.GetComponent<Text>();
+			t.text = text;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.UpperLeft;
+			go.AddComponent<LayoutElement>().minHeight = 18;
+			return t;
+		}
+
+		private static Button AddButton(Transform parent, string label, Action onClick)
+		{
+			var go = new GameObject(label, typeof(Image), typeof(Button));
+			go.transform.SetParent(parent, false);
+			go.GetComponent<Image>().color = new Color(0.2f, 0.4f, 0.6f, 0.85f);
+			var btn = go.GetComponent<Button>();
+			btn.onClick.AddListener(() => onClick?.Invoke());
+			go.AddComponent<LayoutElement>().minHeight = 36;
+
+			var textGo = new GameObject("Text", typeof(Text));
+			textGo.transform.SetParent(go.transform, false);
+			var rt = (RectTransform)textGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = Vector2.zero;
+			rt.offsetMax = Vector2.zero;
+			var t = textGo.GetComponent<Text>();
+			t.text = label;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.MiddleCenter;
+			return btn;
+		}
+	}
+}
diff --git a/Samples~/DeepLinkRouter/README.md b/Samples~/DeepLinkRouter/README.md
new file mode 100644
index 0000000..849c845
--- /dev/null
+++ b/Samples~/DeepLinkRouter/README.md
@@ -0,0 +1,49 @@
+# Deep Link Router
+
+Routing pattern sample. Demonstrates `IDeepLinkRouter.MapRoute` with three routes (`/promo/:id`, `/profile/:userId`, `/settings`) and a fall-through "unmatched" path.
+
+## Setup
+
+1. Import via `Window > Package Manager > GameLovers.MobileServices > Samples > Deep Link Router`.
+2. Create an empty scene and add `DeepLinkRouterUI` to a GameObject.
+3. Build & install to device for the real cold-start replay path; in the editor the buttons exercise the `TryDispatch` API only (no OS-level link delivery).
+
+## Routes registered
+
+| Pattern | Example URI | Captured params |
+|---------|-------------|-----------------|
+| `/promo/:id` | `myapp://promo/spring2026` | `{ "id": "spring2026" }` |
+| `/profile/:userId` | `myapp://profile/abc123` | `{ "userId": "abc123" }` |
+| `/settings` | `myapp://settings` | `{}` |
+
+## Path-pattern syntax
+
+- Literal segments match exactly (case-insensitive).
+- Segments prefixed with `:` capture into the params dictionary.
+- Routes are checked in registration order; first match wins.
+
+## Testing the OS-level launch path
+
+### iOS (Simulator)
+
+```bash
+xcrun simctl openurl booted "myapp://promo/spring2026"
+```
+
+The simulator launches the installed app and `Application.absoluteURL` carries the URI on cold-start. The router's constructor subscribes synchronously, so `DeepLinkService` replays the launch URL to the router immediately.
+
+### Android (`adb`)
+
+```bash
+adb shell am start -W -a android.intent.action.VIEW -d "myapp://promo/spring2026" com.your.bundle.id
+```
+
+Note the `-d "<uri>"` argument and your app's full package id at the end. You'll need an `<intent-filter>` in your `AndroidManifest.xml` declaring `myapp` as a scheme.
+
+## Cold-start replay caveat
+
+`DeepLinkService` replays the cold-start link to the **first** subscriber only. The router IS the first subscriber (constructed in the sample's `Awake`), so the replay is intercepted by it automatically. If your real app sets up additional subscribers, attach the router first.
+
+## Types
+
+`GameLovers.MobileServices.Samples.DeepLinkRouter` — sample-only types, NOT package public API.
diff --git a/Samples~/HapticsPalette/HapticsPaletteUI.cs b/Samples~/HapticsPalette/HapticsPaletteUI.cs
new file mode 100644
index 0000000..699e2fd
--- /dev/null
+++ b/Samples~/HapticsPalette/HapticsPaletteUI.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Collections.Generic;
+using GameLovers.MobileServices.Haptics;
+using UnityEngine;
+using UnityEngine.UI;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Samples.HapticsPalette
+{
+	/// <summary>
+	/// Designer-focused haptic exploration sample — preset grid + sequence recorder + replay.
+	/// See the per-sample <c>README.md</c>.
+	/// </summary>
+	public sealed class HapticsPaletteUI : MonoBehaviour
+	{
+		private IHapticsService _haptics;
+		private readonly List<(HapticPreset preset, float delay)> _sequence = new List<(HapticPreset, float)>();
+		private float _lastTriggerTime;
+		private Text _statusLabel;
+		private Text _sequenceLabel;
+
+		private void Awake()
+		{
+			_haptics = new HapticsService();
+		}
+
+		private void Start()
+		{
+			BuildUi();
+		}
+
+		private void OnDestroy()
+		{
+			_haptics?.StopCurrentHaptic();
+		}
+
+		private void BuildUi()
+		{
+			var canvasGo = new GameObject("HapticsCanvas");
+			var canvas = canvasGo.AddComponent<Canvas>();
+			canvas.renderMode = RenderMode.ScreenSpaceOverlay;
+			canvasGo.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
+			canvasGo.AddComponent<GraphicRaycaster>();
+
+			var layoutGo = new GameObject("Layout", typeof(RectTransform), typeof(VerticalLayoutGroup));
+			layoutGo.transform.SetParent(canvas.transform, false);
+			var rt = (RectTransform)layoutGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = new Vector2(16, 16);
+			rt.offsetMax = new Vector2(-16, -16);
+			var v = layoutGo.GetComponent<VerticalLayoutGroup>();
+			v.spacing = 8;
+			v.childForceExpandHeight = false;
+			v.childForceExpandWidth = true;
+
+			AddHeader(layoutGo.transform, "Haptics Palette");
+			_statusLabel = AddLabel(layoutGo.transform, $"IsSupported: {_haptics.IsSupported}");
+
+			AddSectionHeader(layoutGo.transform, "Presets");
+			var gridGo = new GameObject("PresetGrid", typeof(RectTransform), typeof(GridLayoutGroup));
+			gridGo.transform.SetParent(layoutGo.transform, false);
+			var grid = gridGo.GetComponent<GridLayoutGroup>();
+			grid.cellSize = new Vector2(200, 60);
+			grid.spacing = new Vector2(8, 8);
+			grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
+			grid.constraintCount = 3;
+			gridGo.AddComponent<LayoutElement>().minHeight = 220;
+
+			foreach (HapticPreset preset in Enum.GetValues(typeof(HapticPreset)))
+			{
+				if (preset == HapticPreset.None) continue;
+				var captured = preset;
+				AddButton(gridGo.transform, preset.ToString(), () =>
+				{
+					var now = Time.realtimeSinceStartup;
+					var delay = _lastTriggerTime > 0 ? now - _lastTriggerTime : 0f;
+					_lastTriggerTime = now;
+					_haptics.PlayPreset(captured);
+					_sequence.Add((captured, delay));
+					if (_sequence.Count > 16) _sequence.RemoveAt(0);
+					UpdateSequenceLabel();
+					_statusLabel.text = $"Last: {captured}";
+				});
+			}
+
+			AddSectionHeader(layoutGo.transform, "Recorded sequence");
+			_sequenceLabel = AddLabel(layoutGo.transform, "(empty)");
+
+			var ctrlRow = new GameObject("Controls", typeof(RectTransform), typeof(HorizontalLayoutGroup));
+			ctrlRow.transform.SetParent(layoutGo.transform, false);
+			var hRow = ctrlRow.GetComponent<HorizontalLayoutGroup>();
+			hRow.spacing = 8;
+			ctrlRow.AddComponent<LayoutElement>().minHeight = 40;
+
+			AddButton(ctrlRow.transform, "Replay", ReplaySequence);
+			AddButton(ctrlRow.transform, "Clear", () =>
+			{
+				_sequence.Clear();
+				UpdateSequenceLabel();
+			});
+			AddButton(ctrlRow.transform, "Stop", () =>
+			{
+				_haptics.StopCurrentHaptic();
+				_statusLabel.text = "Stopped.";
+			});
+		}
+
+		private void UpdateSequenceLabel()
+		{
+			if (_sequence.Count == 0)
+			{
+				_sequenceLabel.text = "(empty)";
+				return;
+			}
+			var sb = new System.Text.StringBuilder();
+			for (var i = 0; i < _sequence.Count; i++)
+			{
+				sb.Append(_sequence[i].preset);
+				sb.Append("(+").Append(_sequence[i].delay.ToString("F2")).Append("s) ");
+			}
+			_sequenceLabel.text = sb.ToString();
+		}
+
+		private void ReplaySequence()
+		{
+			if (_sequence.Count == 0) return;
+			StartCoroutine(ReplayCoroutine());
+		}
+
+		private System.Collections.IEnumerator ReplayCoroutine()
+		{
+			foreach (var (preset, delay) in _sequence)
+			{
+				if (delay > 0f)
+				{
+					yield return new WaitForSecondsRealtime(delay);
+				}
+				_haptics.PlayPreset(preset);
+				_statusLabel.text = $"Replay: {preset}";
+			}
+			_statusLabel.text = "Replay finished.";
+		}
+
+		// ---- UI helpers (duplicated to keep each sample self-contained) ----
+
+		private static void AddHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 22;
+			t.fontStyle = FontStyle.Bold;
+		}
+
+		private static void AddSectionHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 16;
+			t.fontStyle = FontStyle.Bold;
+			t.color = new Color(0.8f, 0.9f, 1f);
+		}
+
+		private static Text AddLabel(Transform parent, string text)
+		{
+			var go = new GameObject("Label", typeof(Text));
+			go.transform.SetParent(parent, false);
+			var t = go.GetComponent<Text>();
+			t.text = text;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.UpperLeft;
+			go.AddComponent<LayoutElement>().minHeight = 18;
+			return t;
+		}
+
+		private static Button AddButton(Transform parent, string label, Action onClick)
+		{
+			var go = new GameObject(label, typeof(Image), typeof(Button));
+			go.transform.SetParent(parent, false);
+			go.GetComponent<Image>().color = new Color(0.2f, 0.4f, 0.6f, 0.85f);
+			var btn = go.GetComponent<Button>();
+			btn.onClick.AddListener(() => onClick?.Invoke());
+
+			var le = go.AddComponent<LayoutElement>();
+			le.minHeight = 36;
+
+			var textGo = new GameObject("Text", typeof(Text));
+			textGo.transform.SetParent(go.transform, false);
+			var rt = (RectTransform)textGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = Vector2.zero;
+			rt.offsetMax = Vector2.zero;
+			var t = textGo.GetComponent<Text>();
+			t.text = label;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.MiddleCenter;
+
+			return btn;
+		}
+	}
+}
diff --git a/Samples~/HapticsPalette/README.md b/Samples~/HapticsPalette/README.md
new file mode 100644
index 0000000..a101632
--- /dev/null
+++ b/Samples~/HapticsPalette/README.md
@@ -0,0 +1,25 @@
+# Haptics Palette
+
+Designer-focused haptic exploration sample.
+
+## Setup
+
+1. Import via `Window > Package Manager > GameLovers.MobileServices > Samples > Haptics Palette`.
+2. Create an empty scene and add `HapticsPaletteUI` to a GameObject.
+3. Pair a device with the editor (haptics are silent in Editor / Standalone — the sample's flow assumes a paired phone).
+
+## Flow
+
+- Tap any preset button to trigger that haptic.
+- The "Recorded sequence" label captures the last 16 triggers and their inter-trigger delays.
+- `Replay` replays the recorded sequence at the original timings (uses `WaitForSecondsRealtime` so `Time.timeScale` doesn't distort).
+- `Clear` empties the recording.
+- `Stop` cancels any active haptic (matters for the looped variants exposed via the Device Simulator panel's Haptics foldout).
+
+## Why it's a separate sample
+
+The interaction model is "tune the feel until it lands" — designers iterating with a paired phone. The kitchen-sink playground is for "smoke-test the API surface" and is the wrong place to dwell on haptic timing because the surrounding UI noise distracts.
+
+## Types
+
+All sample types live in `GameLovers.MobileServices.Samples.HapticsPalette` — NOT package public API.
diff --git a/Samples~/MobileServicesPlayground/MobileServicesPlaygroundUI.cs b/Samples~/MobileServicesPlayground/MobileServicesPlaygroundUI.cs
new file mode 100644
index 0000000..2a8e349
--- /dev/null
+++ b/Samples~/MobileServicesPlayground/MobileServicesPlaygroundUI.cs
@@ -0,0 +1,331 @@
+using System;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Gestures;
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.NativeUi;
+using GameLovers.MobileServices.Notifications;
+using UnityEngine;
+using UnityEngine.UI;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Samples.MobileServicesPlayground
+{
+	/// <summary>
+	/// Kitchen-sink playground wiring every Mobile Services subsystem into a single canvas.
+	/// See the per-sample <c>README.md</c>.
+	/// </summary>
+	public sealed class MobileServicesPlaygroundUI : MonoBehaviour
+	{
+		[Header("Notification channel id (must be registered before scheduling)")]
+		[SerializeField] private string _channelId = "default";
+
+		private IDeviceService _device;
+		private IHapticsService _haptics;
+		private INotificationService _notifications;
+		private GestureController _gestures;
+
+		private Text _log;
+		private Text _deviceLabel;
+		private Text _safeAreaLabel;
+		private RectTransform _safeAreaPanel;
+
+		private void Awake()
+		{
+			_device = new DeviceService();
+			_haptics = new HapticsService();
+			_notifications = new MobileNotificationService(
+				new GameNotificationChannel(_channelId, "Default", "Playground demo channel"));
+		}
+
+		private void Start()
+		{
+			BuildUi();
+			WireDeviceEvents();
+			WireDeepLink();
+			WireGestures();
+		}
+
+		private void OnDestroy()
+		{
+			(_device as IDisposable)?.Dispose();
+			if (_gestures != null)
+			{
+				_gestures.Swiped -= OnSwiped;
+				_gestures.Tapped -= OnTapped;
+			}
+#if UNITY_EDITOR
+			// Pair of the editor-only TouchSimulation.Enable() in WireGestures.
+			UnityEngine.InputSystem.EnhancedTouch.TouchSimulation.Disable();
+#endif
+		}
+
+		private void Update()
+		{
+			// Sample applies safe-area padding to the colored panel every frame for snappy
+			// orientation/notch response. Production code usually subscribes to
+			// IDeviceService.SafeArea.OnSafeAreaChanged instead.
+			if (_safeAreaPanel != null)
+			{
+				var area = _device.SafeArea.SafeArea;
+				var w = Screen.width;
+				var h = Screen.height;
+				_safeAreaPanel.anchorMin = new Vector2(area.xMin / w, area.yMin / h);
+				_safeAreaPanel.anchorMax = new Vector2(area.xMax / w, area.yMax / h);
+			}
+
+			if (_deviceLabel != null)
+			{
+				_deviceLabel.text =
+					$"Battery {_device.Battery.Level:P0} ({_device.Battery.Status}) | LPM {_device.Battery.IsLowPowerMode}\n" +
+					$"KeepAwake {_device.ScreenWake.KeepAwake}\n" +
+					$"ATT {_device.Att.CurrentStatus}";
+			}
+			if (_safeAreaLabel != null)
+			{
+				_safeAreaLabel.text = $"Safe area: {_device.SafeArea.SafeArea}";
+			}
+		}
+
+		private void BuildUi()
+		{
+			var canvasGo = new GameObject("PlaygroundCanvas");
+			var canvas = canvasGo.AddComponent<Canvas>();
+			canvas.renderMode = RenderMode.ScreenSpaceOverlay;
+			canvasGo.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
+			canvasGo.AddComponent<GraphicRaycaster>();
+
+			_safeAreaPanel = NewPanel("SafeAreaPanel", canvas.transform, new Color(0.1f, 0.18f, 0.25f, 0.9f));
+			_safeAreaPanel.anchorMin = Vector2.zero;
+			_safeAreaPanel.anchorMax = Vector2.one;
+			_safeAreaPanel.offsetMin = Vector2.zero;
+			_safeAreaPanel.offsetMax = Vector2.zero;
+
+			var layoutGroup = NewVerticalLayout(_safeAreaPanel, padding: 8);
+
+			AddHeader(layoutGroup.transform, "Mobile Services Playground");
+
+			_safeAreaLabel = AddLabel(layoutGroup.transform, "Safe area: …");
+			_deviceLabel = AddLabel(layoutGroup.transform, "Device state…");
+
+			AddSectionHeader(layoutGroup.transform, "Native UI");
+			AddButton(layoutGroup.transform, "Show Alert (modal)", () => NativeUiService.ShowAlertPopUp(
+				false, "Delete Save?", "This cannot be undone.",
+				new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel, Callback = () => Log("Alert: Cancel") },
+				new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, Callback = () => Log("Alert: Delete") }));
+			AddButton(layoutGroup.transform, "Show Action Sheet", () => NativeUiService.ShowAlertPopUp(
+				true, "Photo options", "Choose an action",
+				new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel, Callback = () => Log("Sheet: Cancel") },
+				new AlertButton { Text = "Replace", Style = AlertButtonStyle.Default, Callback = () => Log("Sheet: Replace") },
+				new AlertButton { Text = "Remove", Style = AlertButtonStyle.Destructive, Callback = () => Log("Sheet: Remove") }));
+			AddButton(layoutGroup.transform, "Show Toast (short)", () => NativeUiService.ShowToastMessage("Item Collected!", false));
+			AddButton(layoutGroup.transform, "Show Toast (long)", () => NativeUiService.ShowToastMessage("Saved to cloud — this is a long toast.", true));
+			AddButton(layoutGroup.transform, "Request Review", NativeUiService.RequestReview);
+			AddButton(layoutGroup.transform, "Share", () => NativeUiService.Share("Check out my high score!", "https://example.com"));
+
+			AddSectionHeader(layoutGroup.transform, "Haptics");
+			foreach (HapticPreset preset in Enum.GetValues(typeof(HapticPreset)))
+			{
+				if (preset == HapticPreset.None) continue;
+				var captured = preset;
+				AddButton(layoutGroup.transform, preset.ToString(), () => { _haptics.PlayPreset(captured); Log($"Haptic: {captured}"); });
+			}
+
+			AddSectionHeader(layoutGroup.transform, "Notifications");
+			AddButton(layoutGroup.transform, "Schedule in 5s", () =>
+			{
+				var n = _notifications.CreateNotification();
+				n.Title = "Playground reward";
+				n.Body = "Your scheduled notification!";
+				n.Channel = _channelId;
+				n.DeliveryTime = DateTime.Now.AddSeconds(5);
+				_notifications.ScheduleNotification(n);
+				Log("Scheduled in 5s");
+			});
+			AddButton(layoutGroup.transform, "Cancel all", () => { _notifications.CancelAllScheduledNotifications(); Log("Cancelled all"); });
+
+			AddSectionHeader(layoutGroup.transform, "Permissions");
+			AddButton(layoutGroup.transform, "Check all (sync)", () =>
+			{
+				foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+				{
+					Log($"Check {p}: {_device.Permissions.Check(p)}");
+				}
+			});
+			foreach (AppPermission p in Enum.GetValues(typeof(AppPermission)))
+			{
+				var captured = p;
+				AddButton(layoutGroup.transform, $"Request {p}", async () =>
+				{
+					var result = await _device.Permissions.RequestAsync(captured);
+					Log($"{captured}: {result}");
+				});
+			}
+
+			AddSectionHeader(layoutGroup.transform, "ATT");
+			AddButton(layoutGroup.transform, "Request ATT", async () =>
+			{
+				var result = await _device.Att.RequestAuthorizationAsync();
+				Log($"ATT: {result}");
+			});
+
+			AddSectionHeader(layoutGroup.transform, "Gestures");
+			AddLabel(layoutGroup.transform, "Swipe or tap anywhere — events print to the log below (editor uses Input System Touch Simulation).");
+
+			AddSectionHeader(layoutGroup.transform, "Other");
+			AddButton(layoutGroup.transform, "Toggle KeepAwake", () => _device.ScreenWake.KeepAwake = !_device.ScreenWake.KeepAwake);
+			AddButton(layoutGroup.transform, "Configure Audio (iOS)", _device.AudioSession.ConfigureForPlayback);
+
+			_log = AddLabel(layoutGroup.transform, "Log:");
+		}
+
+		private void WireDeviceEvents()
+		{
+			_device.Battery.OnLevelChanged       += () => Log($"Battery {_device.Battery.Level:P0}");
+			_device.Battery.OnStatusChanged      += () => Log($"Battery status {_device.Battery.Status}");
+			_device.Battery.OnLowPowerModeChanged += () => Log($"LPM {_device.Battery.IsLowPowerMode}");
+		}
+
+		private void WireDeepLink()
+		{
+			_device.DeepLink.OnLinkActivated += uri => Log($"Deep link: {uri}");
+		}
+
+		private void WireGestures()
+		{
+			// GestureController is a MonoBehaviour; it enables EnhancedTouchSupport in OnEnable.
+			_gestures = gameObject.AddComponent<GestureController>();
+			_gestures.Swiped += OnSwiped;
+			_gestures.Tapped += OnTapped;
+#if UNITY_EDITOR
+			// Convert editor mouse drags/clicks into touches so the gesture demo works without a device.
+			UnityEngine.InputSystem.EnhancedTouch.TouchSimulation.Enable();
+#endif
+		}
+
+		private void Log(string message)
+		{
+			Debug.Log($"[Playground] {message}");
+			if (_log == null) return;
+			var lines = _log.text?.Split('\n') ?? Array.Empty<string>();
+			var keep = Math.Max(0, lines.Length - 8);
+			var sb = new System.Text.StringBuilder();
+			sb.AppendLine("Log:");
+			for (var i = keep; i < lines.Length; i++)
+			{
+				sb.AppendLine(lines[i]);
+			}
+			sb.AppendLine(message);
+			_log.text = sb.ToString();
+		}
+
+		private void OnSwiped(SwipeInput swipe) => Log($"Swiped {swipe.SwipeDirection} (vel {swipe.SwipeVelocity:F1})");
+		private void OnTapped(TapInput tap)     => Log($"Tapped at {tap.PressPosition}");
+
+		// ---- UI helpers ----
+
+		private static RectTransform NewPanel(string name, Transform parent, Color color)
+		{
+			var go = new GameObject(name, typeof(Image));
+			go.transform.SetParent(parent, false);
+			go.GetComponent<Image>().color = color;
+			return (RectTransform)go.transform;
+		}
+
+		private static VerticalLayoutGroup NewVerticalLayout(RectTransform parent, int padding)
+		{
+			var go = new GameObject("Layout");
+			go.transform.SetParent(parent, false);
+			var rt = go.AddComponent<RectTransform>();
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = new Vector2(padding, padding);
+			rt.offsetMax = new Vector2(-padding, -padding);
+
+			var sr = go.AddComponent<ScrollRect>();
+			var contentGo = new GameObject("Content");
+			contentGo.transform.SetParent(go.transform, false);
+			var contentRt = contentGo.AddComponent<RectTransform>();
+			contentRt.anchorMin = new Vector2(0, 1);
+			contentRt.anchorMax = new Vector2(1, 1);
+			contentRt.pivot = new Vector2(0.5f, 1f);
+			contentRt.sizeDelta = new Vector2(0, 1200);
+
+			var layout = contentGo.AddComponent<VerticalLayoutGroup>();
+			layout.spacing = 4;
+			layout.padding = new RectOffset(8, 8, 8, 8);
+			layout.childForceExpandHeight = false;
+			layout.childForceExpandWidth = true;
+			contentGo.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+
+			sr.content = contentRt;
+			sr.horizontal = false;
+			sr.vertical = true;
+			return layout;
+		}
+
+		private static void AddHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 24;
+			t.fontStyle = FontStyle.Bold;
+		}
+
+		private static void AddSectionHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 16;
+			t.fontStyle = FontStyle.Bold;
+			t.color = new Color(0.8f, 0.9f, 1f);
+		}
+
+		private static Text AddLabel(Transform parent, string text)
+		{
+			var go = new GameObject("Label", typeof(Text));
+			go.transform.SetParent(parent, false);
+			var t = go.GetComponent<Text>();
+			t.text = text;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.UpperLeft;
+			var le = go.AddComponent<LayoutElement>();
+			le.minHeight = 18;
+			return t;
+		}
+
+		private static Button AddButton(Transform parent, string label, Action onClick)
+		{
+			var go = new GameObject(label, typeof(Image), typeof(Button));
+			go.transform.SetParent(parent, false);
+			go.GetComponent<Image>().color = new Color(0.2f, 0.4f, 0.6f, 0.85f);
+
+			var btn = go.GetComponent<Button>();
+			btn.onClick.AddListener(() => onClick?.Invoke());
+
+			var le = go.AddComponent<LayoutElement>();
+			le.minHeight = 36;
+
+			var textGo = new GameObject("Text", typeof(Text));
+			textGo.transform.SetParent(go.transform, false);
+			var rt = (RectTransform)textGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = Vector2.zero;
+			rt.offsetMax = Vector2.zero;
+			var t = textGo.GetComponent<Text>();
+			t.text = label;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.MiddleCenter;
+
+			return btn;
+		}
+
+		private static Button AddButton(Transform parent, string label, Func<Task> onClick)
+		{
+			return AddButton(parent, label, () => _ = onClick());
+		}
+	}
+}
diff --git a/Samples~/MobileServicesPlayground/README.md b/Samples~/MobileServicesPlayground/README.md
new file mode 100644
index 0000000..02f0330
--- /dev/null
+++ b/Samples~/MobileServicesPlayground/README.md
@@ -0,0 +1,36 @@
+# Mobile Services Playground
+
+Kitchen-sink runtime sample that wires every Mobile Services subsystem into one screen of buttons and live status labels.
+
+## Setup
+
+1. Import the sample via `Window > Package Manager > GameLovers.MobileServices > Samples > Mobile Services Playground`.
+2. Create an empty scene (or use any existing one).
+3. Create an empty GameObject and add the `MobileServicesPlaygroundUI` MonoBehaviour to it.
+4. Enter Play mode. The runtime builds a Canvas + Vertical scrolling layout at runtime — no scene asset is shipped with this sample on purpose (zero asset dependencies, easy diff in PRs).
+
+This is the **breadth** sample — one representative call per subsystem so you can confirm everything is wired. For **depth** in a single subsystem, see the focused samples: `HapticsPalette` (full haptics surface — custom intensity, looping, sequence record/replay, Stop), `NotificationsScheduler` (operating modes, multi-channel, reschedule, delivered/expired events), and `DeepLinkRouter` (the `IDeepLinkRouter` pattern-routing layer).
+
+## Panels
+
+- **Native UI** — modal alert, **action sheet**, short + long toast, review prompt, share sheet.
+- **Haptics** — fires every preset in the catalogue (a representative subset of the haptics surface; see `HapticsPalette` for custom intensity, looping, and sequence record/replay).
+- **Notifications** — schedule-in-5s + cancel-all, against the `default` channel that the script registers in `Awake` (see `NotificationsScheduler` for modes / channels / reschedule).
+- **Permissions** — a sync `Check all` button + an async `Request` button per `AppPermission`.
+- **ATT** — Request authorization (no-op on Android / Editor).
+- **Gestures** — a live `GestureController` is attached to the host GameObject; swipe / tap anywhere and the events print to the log. In the editor, Input System Touch Simulation is enabled so mouse drags/clicks register as touches.
+- **Other** — KeepAwake toggle + iOS audio session configure.
+- **Deep links** — subscribes to the raw `IDeepLinkService.OnLinkActivated` and logs the URI (see `DeepLinkRouter` for pattern routing).
+- **Log** — last 8 entries printed by the buttons.
+
+The panel itself is wrapped in a colored `Image` that tracks the device safe area every frame so the playground also demonstrates `ISafeAreaService` without a separate sample.
+
+## Notes
+
+- Real native UI / haptics / notifications only fire on device. In Editor they log to console (per the package's documented no-op behaviour).
+- The Mobile Services panel in Unity's Device Simulator (`Window > General > Device Simulator`) surfaces this sample's live state when both the playground scene is in Play mode and the Device Simulator is open.
+- The same panel paints platform-shaped mocks of every native UI surface the buttons trigger, rendered inside the simulated phone screen.
+
+## Types
+
+All sample types live in `GameLovers.MobileServices.Samples.MobileServicesPlayground` — they are NOT part of the public package API. Do not document them as such anywhere.
diff --git a/Samples~/NotificationsScheduler/NotificationsSchedulerUI.cs b/Samples~/NotificationsScheduler/NotificationsSchedulerUI.cs
new file mode 100644
index 0000000..9110731
--- /dev/null
+++ b/Samples~/NotificationsScheduler/NotificationsSchedulerUI.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using GameLovers.MobileServices.Notifications;
+using UnityEngine;
+using UnityEngine.UI;
+
+// ReSharper disable once CheckNamespace
+namespace GameLovers.MobileServices.Samples.NotificationsScheduler
+{
+	/// <summary>
+	/// Notifications lifecycle sample — channel CRUD, <see cref="OperatingMode"/> toggles, and the
+	/// background-foreground round trip. See the per-sample <c>README.md</c>.
+	/// </summary>
+	public sealed class NotificationsSchedulerUI : MonoBehaviour
+	{
+		private INotificationService _service;
+		private readonly List<GameNotificationChannel> _channels = new List<GameNotificationChannel>();
+		private Text _log;
+		private Text _channelsLabel;
+		private Text _modeLabel;
+		private OperatingMode _currentMode = OperatingMode.NoQueue;
+
+		private void Awake()
+		{
+			_channels.Add(new GameNotificationChannel("default", "Default", "Default channel"));
+			_service = new MobileNotificationService(_channels.ToArray());
+		}
+
+		private void Start()
+		{
+			BuildUi();
+			_service.OnLocalNotificationDeliveredEvent += pending =>
+				Log($"Delivered while foreground: {pending.Notification.Title}");
+			_service.OnLocalNotificationExpiredEvent += pending =>
+				Log($"Expired (queued + foregrounded): {pending.Notification.Title}");
+		}
+
+		private void OnDestroy()
+		{
+			// MobileNotificationService doesn't expose a Dispose; the host GameObject is
+			// DontDestroyOnLoad. In a "reset game" flow you'd destroy the GameObject by name —
+			// see the package gotchas in AGENTS.md §4. We leave the demo host in place.
+		}
+
+		private void BuildUi()
+		{
+			var canvasGo = new GameObject("Canvas");
+			var canvas = canvasGo.AddComponent<Canvas>();
+			canvas.renderMode = RenderMode.ScreenSpaceOverlay;
+			canvasGo.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
+			canvasGo.AddComponent<GraphicRaycaster>();
+
+			var layoutGo = new GameObject("Layout", typeof(RectTransform), typeof(VerticalLayoutGroup));
+			layoutGo.transform.SetParent(canvas.transform, false);
+			var rt = (RectTransform)layoutGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = new Vector2(16, 16);
+			rt.offsetMax = new Vector2(-16, -16);
+			var v = layoutGo.GetComponent<VerticalLayoutGroup>();
+			v.spacing = 8;
+			v.childForceExpandHeight = false;
+			v.childForceExpandWidth = true;
+
+			AddHeader(layoutGo.transform, "Notifications Scheduler");
+			_modeLabel = AddLabel(layoutGo.transform, $"Mode: {_currentMode}");
+			_channelsLabel = AddLabel(layoutGo.transform, BuildChannelsLabel());
+
+			AddSectionHeader(layoutGo.transform, "Schedule");
+			AddButton(layoutGo.transform, "Schedule in 5s",  () => ScheduleIn(TimeSpan.FromSeconds(5), reschedule: false));
+			AddButton(layoutGo.transform, "Schedule in 30s", () => ScheduleIn(TimeSpan.FromSeconds(30), reschedule: false));
+			AddButton(layoutGo.transform, "Schedule with Reschedule=true (30s)", () => ScheduleIn(TimeSpan.FromSeconds(30), reschedule: true));
+			AddButton(layoutGo.transform, "Cancel all", () => { _service.CancelAllScheduledNotifications(); Log("Cancelled all"); });
+
+			AddSectionHeader(layoutGo.transform, "Channels");
+			AddButton(layoutGo.transform, "Add 'rewards' channel", () =>
+			{
+				if (TryAddChannel("rewards", "Rewards", "Daily reward reminders"))
+				{
+					Log("Added channel 'rewards'");
+				}
+			});
+
+			AddSectionHeader(layoutGo.transform, "Operating mode");
+			foreach (OperatingMode m in Enum.GetValues(typeof(OperatingMode)))
+			{
+				var captured = m;
+				AddButton(layoutGo.transform, m.ToString(), () =>
+				{
+					// Genuinely flips the live service mode — takes effect on the next schedule /
+					// foreground-background transition. Queue mode is what makes the
+					// OnLocalNotificationExpiredEvent reachable.
+					_service.Mode = captured;
+					_currentMode = _service.Mode;
+					_modeLabel.text = $"Mode: {_currentMode}";
+					Log($"Switched mode → {_currentMode}");
+				});
+			}
+
+			_log = AddLabel(layoutGo.transform, "Log:");
+		}
+
+		private string BuildChannelsLabel()
+		{
+			var sb = new System.Text.StringBuilder("Channels: ");
+			foreach (var c in _channels) sb.Append(c.Id).Append(' ');
+			return sb.ToString();
+		}
+
+		private bool TryAddChannel(string id, string name, string description)
+		{
+			foreach (var c in _channels) if (c.Id == id) return false;
+			_channels.Add(new GameNotificationChannel(id, name, description));
+			_channelsLabel.text = BuildChannelsLabel();
+			return true;
+		}
+
+		private void ScheduleIn(TimeSpan span, bool reschedule)
+		{
+			var n = _service.CreateNotification();
+			n.Title = $"Test +{span.TotalSeconds:F0}s";
+			n.Body = $"Reschedule={reschedule}";
+			n.Channel = _channels[0].Id;
+			n.DeliveryTime = DateTime.Now + span;
+			var pending = _service.ScheduleNotification(n);
+			if (reschedule) pending.Reschedule = true;
+			Log($"Scheduled {n.Title}");
+		}
+
+		private void Log(string message)
+		{
+			Debug.Log($"[Notifications Scheduler] {message}");
+			if (_log == null) return;
+			var lines = _log.text?.Split('\n') ?? Array.Empty<string>();
+			var keep = Math.Max(0, lines.Length - 8);
+			var sb = new System.Text.StringBuilder();
+			sb.AppendLine("Log:");
+			for (var i = keep; i < lines.Length; i++) sb.AppendLine(lines[i]);
+			sb.AppendLine(message);
+			_log.text = sb.ToString();
+		}
+
+		// ---- UI helpers ----
+
+		private static void AddHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 22;
+			t.fontStyle = FontStyle.Bold;
+		}
+
+		private static void AddSectionHeader(Transform parent, string text)
+		{
+			var t = AddLabel(parent, text);
+			t.fontSize = 16;
+			t.fontStyle = FontStyle.Bold;
+			t.color = new Color(0.8f, 0.9f, 1f);
+		}
+
+		private static Text AddLabel(Transform parent, string text)
+		{
+			var go = new GameObject("Label", typeof(Text));
+			go.transform.SetParent(parent, false);
+			var t = go.GetComponent<Text>();
+			t.text = text;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.UpperLeft;
+			go.AddComponent<LayoutElement>().minHeight = 18;
+			return t;
+		}
+
+		private static Button AddButton(Transform parent, string label, Action onClick)
+		{
+			var go = new GameObject(label, typeof(Image), typeof(Button));
+			go.transform.SetParent(parent, false);
+			go.GetComponent<Image>().color = new Color(0.2f, 0.4f, 0.6f, 0.85f);
+			var btn = go.GetComponent<Button>();
+			btn.onClick.AddListener(() => onClick?.Invoke());
+			var le = go.AddComponent<LayoutElement>();
+			le.minHeight = 36;
+			var textGo = new GameObject("Text", typeof(Text));
+			textGo.transform.SetParent(go.transform, false);
+			var rt = (RectTransform)textGo.transform;
+			rt.anchorMin = Vector2.zero;
+			rt.anchorMax = Vector2.one;
+			rt.offsetMin = Vector2.zero;
+			rt.offsetMax = Vector2.zero;
+			var t = textGo.GetComponent<Text>();
+			t.text = label;
+			t.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
+			t.fontSize = 13;
+			t.color = Color.white;
+			t.alignment = TextAnchor.MiddleCenter;
+			return btn;
+		}
+	}
+}
diff --git a/Samples~/NotificationsScheduler/README.md b/Samples~/NotificationsScheduler/README.md
new file mode 100644
index 0000000..e026132
--- /dev/null
+++ b/Samples~/NotificationsScheduler/README.md
@@ -0,0 +1,24 @@
+# Notifications Scheduler
+
+Lifecycle-focused notifications sample. Educates on the three concerns the kitchen-sink playground deliberately hides:
+
+1. **Channel CRUD** — add channels at runtime and observe the first-channel-becomes-default-on-Android rule.
+2. **`OperatingMode` toggles** — `NoQueue` / `Queue` / `QueueAndClear` / `QueueClearAndReschedule`. The buttons set `INotificationService.Mode` directly, so the change takes effect live on the next schedule / foreground-background transition.
+3. **Persistence-on-background round trip** — the `OnLocalNotificationDeliveredEvent` and `OnLocalNotificationExpiredEvent` events log what happens when you background and foreground the app while a queued notification is pending.
+
+## Setup
+
+1. Import via `Window > Package Manager > GameLovers.MobileServices > Samples > Notifications Scheduler`.
+2. Create an empty scene and add `NotificationsSchedulerUI` to a GameObject.
+3. Build & deploy to device for the full background-foreground round trip — the foreground-cancel path only fires when `OnApplicationFocus(false)` then `(true)` transitions through, which the Editor's run-without-focus model doesn't truly exercise.
+
+## What it covers
+
+- Schedule a notification with `Reschedule = false` (default) and one with `Reschedule = true`. Then put the app in the background, wait past the delivery time, foreground it, and observe:
+  - In `QueueClearAndReschedule` mode, the `Reschedule = true` entry is re-queued, the other one fires `OnLocalNotificationExpiredEvent`.
+  - In `QueueAndClear` mode, both fire `OnLocalNotificationExpiredEvent`.
+  - In `NoQueue` mode (default), the OS delivered the notification at the requested time regardless of foreground/background — you see the heads-up banner on a real device.
+
+## Types
+
+`GameLovers.MobileServices.Samples.NotificationsScheduler` — sample-only types, NOT package public API.
diff --git a/Samples~/README.md b/Samples~/README.md
new file mode 100644
index 0000000..b82418a
--- /dev/null
+++ b/Samples~/README.md
@@ -0,0 +1,35 @@
+# Mobile Services Samples
+
+Four samples ship with `com.gamelovers.mobileservices`.
+
+| Sample | Purpose | Setup |
+|--------|---------|-------|
+| **MobileServicesPlayground** | Kitchen-sink wiring proof. Buttons fire every API call across every subsystem; the canvas wraps a safe-area panel so the playground also doubles as a safe-area visualizer. | Drop `MobileServicesPlaygroundUI` on a GameObject. |
+| **HapticsPalette** | Designer iteration tool. 3x3 preset grid + sequence recorder for "tune the feel" loops on a paired device. | Drop `HapticsPaletteUI` on a GameObject. |
+| **NotificationsScheduler** | Lifecycle demo. Channel CRUD, `OperatingMode` toggles, background-foreground round trip with reschedule semantics. | Drop `NotificationsSchedulerUI` on a GameObject and deploy to device. |
+| **DeepLinkRouter** | Pattern demo for the `IDeepLinkRouter.MapRoute` API. Cold-start replay instructions for both platforms. | Drop `DeepLinkRouterUI` on a GameObject; follow the per-sample README for `xcrun simctl` / `adb shell am start` test commands. |
+
+## Why code-only samples (no scene / prefab assets)
+
+The samples build their UI at runtime via legacy `UnityEngine.UI` (Canvas + VerticalLayoutGroup + Image + Text + Button). This is intentional:
+
+- **Zero asset dependencies** — no `.unity` scenes, no `.prefab` files, no deterministic-GUID `.meta` files to keep in sync. The sample tree is just `*.cs` + `README.md`.
+- **Diff-friendly** — code reviewers see exactly what the sample does at the source level; YAML-serialised scenes are notoriously hard to review.
+- **Easy to drop into any scene** — the user adds the component to any GameObject in any scene and presses Play. No "now open `<sample>.unity`" step.
+
+This is a deliberate divergence from `com.gamelovers.services` and `com.gamelovers.uiservice`, both of which ship `.unity` scenes with hand-authored deterministic GUIDs. Those samples include prefab references and a structured scene hierarchy that genuinely need the scene file; the mobile samples don't.
+
+## Sample-only types
+
+All sample types live in `GameLovers.MobileServices.Samples.<SampleName>` namespaces. They are **NOT** part of the public package API surface. When updating any sample's README or the main package README, never describe these types as if they were package API — workspace AGENTS.md explicitly calls this out as a recurring documentation-drift trap.
+
+## On-device testing
+
+Most surfaces (haptics, native UI, real notifications, ATT, native deep-link delivery) only fully exercise on a physical device or simulator. In Unity Editor:
+
+- `NativeUiService` logs to console.
+- `HapticsService` runs the `EditorHapticsBackend` (logs).
+- `MobileNotificationService` returns an `EditorGameNotification` — `Schedule` doesn't actually queue with the OS.
+- `IPermissionsService.RequestAsync` short-circuits to `Granted` (or whatever `EditorPlatformSimulator.QueuePermissionResult` was last asked to return).
+
+The Mobile Services panel inside Unity's Device Simulator (`Window > General > Device Simulator`) bridges the editor gap by painting platform-shaped mocks of every native UI surface inside the simulated phone screen; open the Device Simulator next to the playground sample for the best in-editor iteration loop.
diff --git a/Tests.meta b/Tests.meta
new file mode 100644
index 0000000..0ce3830
--- /dev/null
+++ b/Tests.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5f209a04a0a25423f934346a08c95472
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md
new file mode 100644
index 0000000..c56910e
--- /dev/null
+++ b/Tests/AGENTS.md
@@ -0,0 +1,96 @@
+# GameLovers.MobileServices Tests - AI Agent Guide
+
+This file contains testing conventions for the `com.gamelovers.mobileservices` package. It is the source of truth when reading, editing, or creating test files under `Tests/`.
+
+For runtime architecture, gotchas, and package-level context, see the parent [`AGENTS.md`](../AGENTS.md).
+
+## 1. Placement Rules (EditMode vs PlayMode)
+- **EditMode / Unit** (`EditMode/Unit/`): Pure-logic types and services whose Editor-platform code path is a simple log / safe no-op. Includes:
+  - `NativeUiService` (Editor logs only)
+  - `HapticsService` exercised through its internal `IHapticsBackend` injection ctor (no `HapticsHost` spawn)
+  - `EditorHapticsBackend`, `NoOpHapticsBackend`
+  - `ActiveGesture`, `SwipeInput`, `TapInput` (gesture math)
+  - `PendingNotification`, `EditorGameNotification`, `GameNotificationChannel`, `OperatingMode`
+  - `ScreenWakeService`, `IosAudioSessionService`, `PermissionsService` (Editor short-circuits to `Granted`), `AttService` (Editor short-circuits to `Authorized`)
+  - `DeviceService` injection ctor (with NSubstitute mocks)
+  - `DeepLinkService` cold-start-absent path (Editor `Application.absoluteURL` is empty)
+  - `SafeAreaContainer` (UI Toolkit `VisualElement`, no host needed)
+  - Use `[Test]`. NSubstitute is referenced only on the EditMode asmdef.
+- **PlayMode / Unit** (`PlayMode/Unit/`): Anything that spawns or relies on `DontDestroyOnLoad` MonoBehaviours or Unity callback frames:
+  - `MobileNotificationService` (creates `GameObject("NotificationService")`)
+  - `HapticsService` auto-stop paths (spawn `HapticsHost`)
+  - `DeviceServicesHost` (LateUpdate / SecondTick / Focus / iOS-LPM fan-out)
+  - `SafeAreaService`, `BatteryService` (depend on `DeviceServicesHost`)
+  - `PermissionsCallbackReceiver`, `AttCallbackReceiver` (need a live MonoBehaviour to receive `UnitySendMessage`-style payloads)
+  - Use `[UnityTest]` returning `IEnumerator`.
+- **PlayMode / Smoke** (`PlayMode/Smoke/`): Lightweight "instantiate without throwing" tests. `GestureController` lives here — driving real `EnhancedTouch` events deterministically requires test-input plumbing that exceeds the smoke-test scope; we only verify lifecycle (enable/disable subscription).
+- **Editor tooling (NOT tested)**: types under `Editor/` are validated by manual editor smoke + on-device builds, not by automated tests. This explicitly includes `MobileServicesDeviceSimulatorPlugin`, `MobileSimulatorRuntimeOverlay`, `MobileSimulatorState`, `MockBuilders`, `EditorPlatformSimulator`, `MobileServicesConfig` / `MobileServicesConfigEditor` / `MobileServicesConfigMenuItems`, `MobileServicesScanner`, `MobileServicesBuildPostprocessor`. The previous `GameLovers.MobileServices.Editor.Tests` assembly was removed in v1.0.0 — see §9 for rationale.
+
+**Decision tree**: if the type spawns a `GameObject`, subscribes to a Unity callback that needs a frame to fire, or relies on an internal MonoBehaviour singleton → **PlayMode**; if it lives under `Editor/` → **not tested**; otherwise → **EditMode**.
+
+## 2. Namespace and Suppression
+All test files use `namespace GameLoversEditor.MobileServices.Tests` with the suppression comment:
+```csharp
+// ReSharper disable once CheckNamespace
+```
+
+This matches the convention established by sibling package `com.gamelovers.services` (namespace `GameLoversEditor.Services.Tests`). The `GameLoversEditor.*` prefix signals "these types live in test-only assemblies" and avoids shadowing runtime namespaces.
+
+## 3. Naming
+- **Test class**: `{TypeName}Test` for EditMode (e.g., `HapticsServiceTest`, `GameNotificationChannelTest`); add a `PlayMode` suffix when an EditMode test class for the same type already exists (e.g., `HapticsServicePlayModeTest`). Smoke tests use `{TypeName}SmokeTest`.
+- **Test method**: `MethodOrBehavior_Condition_ExpectedResult` — e.g., `PlayPreset_None_NoBackendCall`, `Ctor_NullNotification_ThrowsArgumentNullException`, `KeepAwake_True_SetsScreenSleepTimeoutNeverSleep`.
+- **SetUp method**: Named `Init()`.
+- **TearDown method**: Named `Dispose()` (when calling `service.Dispose()`) or `Cleanup()` (when doing `Object.Destroy` / `DeviceServicesHost.ResetForTests()`).
+
+## 4. Mock / Helper Types
+- Define mock interfaces and classes as **nested types** inside the test class when needed.
+- EditMode tests use **NSubstitute** (`Substitute.For<T>()`) for interface mocking — referenced only in the EditMode asmdef.
+- For internal types whose construction shape is awkward to mock (e.g. `IHapticsBackend`), prefer hand-written `private sealed class FakeXBackend : IXBackend` nested inside the test class with explicit counters; this keeps the test reading like the production call sequence.
+- PlayMode tests use concrete MonoBehaviour stubs / direct interaction with the real type. NSubstitute is **not** referenced in the PlayMode asmdef.
+
+## 5. Black-Box Testing Policy
+- **No reflection-based testing.** Tests must exercise the runtime code through its public/internal API surface only — no `BindingFlags.NonPublic` reads or writes of private fields, properties, or events.
+- Internal types and members are accessible thanks to `Runtime/AssemblyInfo.cs` granting `[assembly: InternalsVisibleTo("GameLovers.MobileServices.{Edit,Play}Mode.Tests")]`. That access is intentional and is **not** considered reflection.
+- If a code path is genuinely unreachable through any black-box surface (e.g. `DeepLinkService` cold-start replay needs `Application.absoluteURL`, which the Editor cannot fabricate), the test for that path is **omitted** rather than worked around. The path is documented in this file (see §9 below) and verified manually on-device.
+
+## 6. Fields and Setup
+- Fields are prefixed with `_` and use **concrete types** (not interfaces): `private HapticsService _haptics;`, `private DeviceServicesHost _host;`.
+- Constants use `PascalCase`: `private const float DefaultLevel = 0.42f;`.
+- `[SetUp]` creates fresh service instances. Services that create or hold references to GameObjects (`MobileNotificationService`, `HapticsService` after a Play* with auto-stop, anything depending on `DeviceServicesHost`) **must** call `Dispose()` and/or `DeviceServicesHost.ResetForTests()` in `[TearDown]`.
+
+## 7. Assertion Style
+- NUnit classic model only: `Assert.AreEqual`, `Assert.AreSame`, `Assert.IsTrue`, `Assert.Throws<T>`, `Assert.DoesNotThrow`, etc.
+- No constraint-model (`Assert.That(...)`) usage.
+- Async tests use `await tcs.Task` (or `await Task.WhenAny(tcs.Task, Task.Delay(timeout))` for timeout safety) inside `[UnityTest]` bodies that yield `null` between awaits — there is no `[Test, Timeout]` story for `Task<T>`-returning APIs in this package.
+
+## 8. PlayMode Test Cleanup
+- `DeviceServicesHost`, `HapticsHost`, `PermissionsCallbackReceiver`, `AttCallbackReceiver`, and the `GameObject("NotificationService")` are all `DontDestroyOnLoad` MonoBehaviours. PlayMode tests that touch them MUST tear down in this order:
+  1. Call `service.Dispose()` (which unsubscribes from the host and removes its handlers from the event lists).
+  2. For host singletons, call the static `ResetForTests()` accessor where one exists (`DeviceServicesHost.ResetForTests()`); otherwise `Object.Destroy(go)` on the GameObject.
+- Without the reset, the next `[SetUp]` will receive the previous test's host instance and event subscriptions → flaky cross-test interference.
+
+## 9. Coverage Gaps (intentional, do NOT regress to test workarounds)
+The following code paths are **not** automated-testable from the EditMode/PlayMode runners and are validated manually on a device build instead. Documented here so future audits don't try to re-cover them:
+- **`NativeUiService`** native paths (iOS `[DllImport]` / Android `AndroidJavaObject`) — the Editor short-circuits log-only; manual smoke on TestFlight / internal Play track.
+- **`HapticsService` iOS/Android backends** (`IosHapticsBackend`, `AndroidHapticsBackend`) — wrap `[DllImport]` and `AndroidJavaObject`; manual smoke on real devices.
+- **`MobileNotificationService` non-Editor flows** (`GameNotificationsMonoBehaviour` queueing/persisting notifications via PlayerPrefs across foreground/background) — Editor `CreateNotification`/`ScheduleNotification` returns the in-memory `EditorGameNotification`; the queue/clear/reschedule semantics are exercised at the `OperatingMode` enum level only.
+- **`DeepLinkService` cold-start replay** — requires `Application.absoluteURL` to be non-empty, which only happens when the OS launches the app with a deep link. Black-box test #59 (cold-start absent) is the only automated coverage; the replay path is verified manually with `xcrun simctl openurl` (iOS) / `adb shell am start -a android.intent.action.VIEW -d <uri>` (Android).
+- **`GestureController` end-to-end gesture detection** — driving `UnityEngine.InputSystem.EnhancedTouch.Touch` deterministically from a test requires the Input System's `InputTestFixture`, which adds a non-trivial setup cost. Only the lifecycle (subscribe/unsubscribe) is smoke-tested; the math is fully covered through `ActiveGesture` / `SwipeInput` / `TapInput` unit tests.
+- **`BatteryService` low-power-mode change events** — driving an `OnIosLowPowerModeChanged` fan-out *through* `BatteryService` requires a `DeviceServicesHost` event invocation that's not reachable without reflection; we instead test the host's fan-out directly (`DeviceServicesHostTest.OnIosLowPowerModeChanged_PublicMethod_FanOutsToSubscribers`) and trust subscription wiring in `BatteryService` ctor.
+- **Editor tooling** — all `Editor/` assembly types are validated manually (open `Window > General > Device Simulator`, drive the Mobile Services plugin panel and its in-Game-view overlay, edit the `MobileServicesConfig` asset via its Inspector and the `Tools > GameLovers > Mobile Services > Select Mobile Services Config` menu, perform a real iOS / Android build — including a multi-locale build to verify the `<locale>.lproj/InfoPlist.strings` emission). Automated tests in this scope were dropped in v1.0.0 (`GameLovers.MobileServices.Editor.Tests` assembly and its five test classes — `EditorPlatformSimulatorTest`, `MobileServicesBuildPostprocessorTest`, `MobileServicesExplorerWindowTest`, `MobileServicesSettingsTest`, `MobileSimulatorWindowTest` — were removed) because they tested UIToolkit wiring rather than behaviour that affects players, and the maintenance cost (UIToolkit visual-tree diffs, EditorWindow lifecycle quirks) exceeded the regression-catch value.
+
+## 10. Test Directory Layout
+
+| Directory | Contents |
+|-----------|----------|
+| `EditMode/Unit/` | NUnit + NSubstitute; pure-logic services, math types, enum sanity, Editor-shortcircuit paths, UI Toolkit container |
+| `PlayMode/Unit/` | `[UnityTest]`; `MobileNotificationService`, `DeviceServicesHost`, host-dependent services, callback receivers, `HapticsService` auto-stop |
+| `PlayMode/Smoke/` | `GestureController` lifecycle smoke |
+
+## 11. Update Policy
+Update this file when:
+- Test conventions change (new asmdef references, assertion style, naming patterns, new test categories)
+- New test directories or categories are added
+- Mock/stub patterns change (e.g., NSubstitute added to the PlayMode asmdef)
+- A coverage gap from §9 becomes testable (e.g., a future Input System `InputTestFixture` adoption could promote `GestureController` from smoke to unit)
+- An editor-tooling type previously listed in §1's not-tested group becomes amenable to automated coverage (e.g. behaviour split out into a runtime-facing helper that no longer depends on UIToolkit / EditorWindow plumbing)
diff --git a/Tests/AGENTS.md.meta b/Tests/AGENTS.md.meta
new file mode 100644
index 0000000..c30f9b8
--- /dev/null
+++ b/Tests/AGENTS.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8b3f1d22a7c245e6a9f4d6bce71a4c12
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/EditMode.meta b/Tests/EditMode.meta
new file mode 100644
index 0000000..7d549cb
--- /dev/null
+++ b/Tests/EditMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 829b0d703f3d44a5f8517828ee3e67af
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef
new file mode 100644
index 0000000..9bd4976
--- /dev/null
+++ b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef
@@ -0,0 +1,26 @@
+{
+    "name": "GameLovers.MobileServices.EditMode.Tests",
+    "rootNamespace": "GameLoversEditor.MobileServices.Tests",
+    "references": [
+        "GameLovers.MobileServices",
+        "UnityEngine.TestRunner",
+        "UnityEditor.TestRunner",
+        "Unity.InputSystem"
+    ],
+    "includePlatforms": [
+        "Editor"
+    ],
+    "excludePlatforms": [],
+    "allowUnsafeCode": false,
+    "overrideReferences": true,
+    "precompiledReferences": [
+        "nunit.framework.dll",
+        "NSubstitute.dll"
+    ],
+    "autoReferenced": false,
+    "defineConstraints": [
+        "UNITY_INCLUDE_TESTS"
+    ],
+    "versionDefines": [],
+    "noEngineReferences": false
+}
diff --git a/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta
new file mode 100644
index 0000000..531c913
--- /dev/null
+++ b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: ff558310a7f534c149f7afcb0b947af4
+AssemblyDefinitionImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/EditMode/Unit.meta b/Tests/EditMode/Unit.meta
new file mode 100644
index 0000000..9be9710
--- /dev/null
+++ b/Tests/EditMode/Unit.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9fdd6e7e7b82741299c12cd727d06fec
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/EditMode/Unit/ActiveGestureTest.cs b/Tests/EditMode/Unit/ActiveGestureTest.cs
new file mode 100644
index 0000000..6655145
--- /dev/null
+++ b/Tests/EditMode/Unit/ActiveGestureTest.cs
@@ -0,0 +1,78 @@
+using GameLovers.MobileServices.Gestures;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class ActiveGestureTest
+	{
+		[Test]
+		public void Ctor_InitializesFieldsToStartPositionTime()
+		{
+			var start = new Vector2(100f, 200f);
+			var gesture = new ActiveGesture(7, start, 1.5);
+
+			Assert.AreEqual(7, gesture.InputId);
+			Assert.AreEqual(start, gesture.StartPosition);
+			Assert.AreEqual(start, gesture.EndPosition);
+			Assert.AreEqual(1.5, gesture.StartTime);
+			Assert.AreEqual(1.5, gesture.EndTime);
+			Assert.AreEqual(1, gesture.Samples);
+			Assert.AreEqual(0f, gesture.TravelDistance);
+		}
+
+		[Test]
+		public void SubmitPoint_SamePosition_SkipsAccumulation()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 0.0);
+
+			gesture.SubmitPoint(Vector2.zero, 0.5);
+
+			Assert.AreEqual(1, gesture.Samples);
+			Assert.AreEqual(0f, gesture.TravelDistance);
+			Assert.AreEqual(0.5, gesture.EndTime);
+		}
+
+		[Test]
+		public void SubmitPoint_StraightLine_TravelDistanceMatchesEuclidean()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 0.0);
+
+			gesture.SubmitPoint(new Vector2(10f, 0f), 0.1);
+			gesture.SubmitPoint(new Vector2(20f, 0f), 0.2);
+			gesture.SubmitPoint(new Vector2(30f, 0f), 0.3);
+
+			Assert.AreEqual(30f, gesture.TravelDistance, 1e-4f);
+			Assert.AreEqual(new Vector2(30f, 0f), gesture.EndPosition);
+		}
+
+		[Test]
+		public void SubmitPoint_StraightLine_SwipeDirectionSamenessApproachesOne()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 0.0);
+
+			for (var i = 1; i <= 10; i++)
+			{
+				gesture.SubmitPoint(new Vector2(i * 5f, 0f), i * 0.05);
+			}
+
+			Assert.GreaterOrEqual(gesture.SwipeDirectionSameness, 0.99f);
+		}
+
+		[Test]
+		public void SubmitPoint_BackAndForth_SamenessLow()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 0.0);
+
+			gesture.SubmitPoint(new Vector2(10f, 0f), 0.1);
+			gesture.SubmitPoint(new Vector2(0f, 0f), 0.2);
+			gesture.SubmitPoint(new Vector2(10f, 0f), 0.3);
+			gesture.SubmitPoint(new Vector2(0f, 0f), 0.4);
+
+			Assert.Less(gesture.SwipeDirectionSameness, 0.5f);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/ActiveGestureTest.cs.meta b/Tests/EditMode/Unit/ActiveGestureTest.cs.meta
new file mode 100644
index 0000000..795a153
--- /dev/null
+++ b/Tests/EditMode/Unit/ActiveGestureTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3fddc2efef1324002ac41f039207f8ac
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/AttServiceTest.cs b/Tests/EditMode/Unit/AttServiceTest.cs
new file mode 100644
index 0000000..8f96618
--- /dev/null
+++ b/Tests/EditMode/Unit/AttServiceTest.cs
@@ -0,0 +1,35 @@
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class AttServiceTest
+	{
+		private AttService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new AttService();
+		}
+
+		[Test]
+		public void CurrentStatus_InEditor_IsAuthorized()
+		{
+			Assert.AreEqual(AttStatus.Authorized, _service.CurrentStatus);
+		}
+
+		[Test]
+		public void RequestAuthorizationAsync_InEditor_ReturnsAuthorized()
+		{
+			Task<AttStatus> task = _service.RequestAuthorizationAsync();
+
+			Assert.IsTrue(task.IsCompleted);
+			Assert.AreEqual(AttStatus.Authorized, task.Result);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/AttServiceTest.cs.meta b/Tests/EditMode/Unit/AttServiceTest.cs.meta
new file mode 100644
index 0000000..69ce310
--- /dev/null
+++ b/Tests/EditMode/Unit/AttServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 21584dddb80fb48aaa3d012d8fb32c6f
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/DeepLinkRouterTest.cs b/Tests/EditMode/Unit/DeepLinkRouterTest.cs
new file mode 100644
index 0000000..3cc102a
--- /dev/null
+++ b/Tests/EditMode/Unit/DeepLinkRouterTest.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class DeepLinkRouterTest
+	{
+		private DeepLinkService _deepLink;
+		private DeepLinkRouter _router;
+
+		[SetUp]
+		public void Init()
+		{
+			_deepLink = new DeepLinkService();
+			_router = new DeepLinkRouter(_deepLink);
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_router.Dispose();
+			_deepLink.Dispose();
+		}
+
+		[Test]
+		public void Ctor_NullDeepLink_Throws()
+		{
+			Assert.Throws<ArgumentNullException>(() => new DeepLinkRouter(null));
+		}
+
+		[Test]
+		public void MapRoute_NullPattern_Throws()
+		{
+			Assert.Throws<ArgumentNullException>(() => _router.MapRoute(null, (_, __) => { }));
+		}
+
+		[Test]
+		public void MapRoute_NullHandler_Throws()
+		{
+			Assert.Throws<ArgumentNullException>(() => _router.MapRoute("/promo/:id", null));
+		}
+
+		[Test]
+		public void TryDispatch_LiteralRoute_Matches()
+		{
+			var fired = 0;
+			_router.MapRoute("/settings", (_, __) => fired++);
+			var ok = _router.TryDispatch(new Uri("myapp://settings"));
+			Assert.IsTrue(ok);
+			Assert.AreEqual(1, fired);
+		}
+
+		[Test]
+		public void TryDispatch_CapturedSegment_PopulatesParams()
+		{
+			IReadOnlyDictionary<string, string> captured = null;
+			_router.MapRoute("/promo/:id", (_, p) => captured = p);
+			var ok = _router.TryDispatch(new Uri("myapp://promo/spring2026"));
+			Assert.IsTrue(ok);
+			Assert.IsNotNull(captured);
+			Assert.AreEqual("spring2026", captured["id"]);
+		}
+
+		[Test]
+		public void TryDispatch_MultiCaptured_PopulatesAllParams()
+		{
+			IReadOnlyDictionary<string, string> captured = null;
+			_router.MapRoute("/profile/:userId/post/:postId", (_, p) => captured = p);
+			var ok = _router.TryDispatch(new Uri("myapp://profile/abc/post/42"));
+			Assert.IsTrue(ok);
+			Assert.AreEqual("abc", captured["userId"]);
+			Assert.AreEqual("42", captured["postId"]);
+		}
+
+		[Test]
+		public void TryDispatch_NoMatch_ReturnsFalse()
+		{
+			_router.MapRoute("/promo/:id", (_, __) => { });
+			var ok = _router.TryDispatch(new Uri("myapp://settings"));
+			Assert.IsFalse(ok);
+		}
+
+		[Test]
+		public void TryDispatch_FirstMatchWins()
+		{
+			var firedFirst = 0;
+			var firedSecond = 0;
+			_router.MapRoute("/promo/:id", (_, __) => firedFirst++);
+			_router.MapRoute("/promo/:id", (_, __) => firedSecond++);
+			_router.TryDispatch(new Uri("myapp://promo/x"));
+			Assert.AreEqual(1, firedFirst);
+			Assert.AreEqual(0, firedSecond);
+		}
+
+		[Test]
+		public void RemoveRoute_RemovesAllMatchingRegistrations()
+		{
+			var fired = 0;
+			_router.MapRoute("/x", (_, __) => fired++);
+			_router.MapRoute("/x", (_, __) => fired++);
+			_router.RemoveRoute("/x");
+			_router.TryDispatch(new Uri("myapp://x"));
+			Assert.AreEqual(0, fired);
+		}
+
+		[Test]
+		public void TryDispatch_NullUri_ReturnsFalse()
+		{
+			_router.MapRoute("/x", (_, __) => { });
+			Assert.IsFalse(_router.TryDispatch(null));
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/DeepLinkRouterTest.cs.meta b/Tests/EditMode/Unit/DeepLinkRouterTest.cs.meta
new file mode 100644
index 0000000..6adbe07
--- /dev/null
+++ b/Tests/EditMode/Unit/DeepLinkRouterTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1523dd8cfe16f4afe894a56ade9189c6
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/DeepLinkServiceTest.cs b/Tests/EditMode/Unit/DeepLinkServiceTest.cs
new file mode 100644
index 0000000..688a134
--- /dev/null
+++ b/Tests/EditMode/Unit/DeepLinkServiceTest.cs
@@ -0,0 +1,53 @@
+using System;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class DeepLinkServiceTest
+	{
+		private DeepLinkService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new DeepLinkService();
+		}
+
+		[TearDown]
+		public void Dispose()
+		{
+			_service.Dispose();
+		}
+
+		[Test]
+		public void Ctor_NoColdStartUrl_PendingColdStartLinkIsNull()
+		{
+			// Application.absoluteURL is empty in the EditMode harness — see Tests/AGENTS.md §9.
+			Assert.IsNull(_service.PendingColdStartLink);
+		}
+
+		[Test]
+		public void Subscribe_NoColdStartLink_HandlerNotInvoked()
+		{
+			Uri received = null;
+			Action<Uri> handler = uri => received = uri;
+
+			_service.OnLinkActivated += handler;
+
+			Assert.IsNull(received);
+
+			_service.OnLinkActivated -= handler;
+		}
+
+		[Test]
+		public void Dispose_DoesNotThrow_AndPendingColdStartLinkIsNull()
+		{
+			Assert.DoesNotThrow(_service.Dispose);
+			Assert.IsNull(_service.PendingColdStartLink);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta b/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta
new file mode 100644
index 0000000..12fb794
--- /dev/null
+++ b/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8e31969d3c2cb430d83b736fd2ba403f
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/DeviceServiceTest.cs b/Tests/EditMode/Unit/DeviceServiceTest.cs
new file mode 100644
index 0000000..5b2afd7
--- /dev/null
+++ b/Tests/EditMode/Unit/DeviceServiceTest.cs
@@ -0,0 +1,100 @@
+using System;
+using GameLovers.MobileServices.Device;
+using NSubstitute;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class DeviceServiceTest
+	{
+		// Marker interfaces co-implemented with IDisposable so NSubstitute can produce a proxy
+		// that satisfies both the umbrella's interface contract AND the IDisposable check inside
+		// DeviceService.Dispose. The DeviceService ctor accepts the parent interface; the more-
+		// derived disposable variant is implicitly convertible to it.
+		public interface ISafeAreaServiceDisposable : ISafeAreaService, IDisposable { }
+		public interface IBatteryServiceDisposable  : IBatteryService,  IDisposable { }
+		public interface IDeepLinkServiceDisposable : IDeepLinkService, IDisposable { }
+
+		private ISafeAreaServiceDisposable _safeArea;
+		private IScreenWakeService _screenWake;
+		private IBatteryServiceDisposable _battery;
+		private IIosAudioSessionService _audioSession;
+		private IPermissionsService _permissions;
+		private IAttService _att;
+		private IDeepLinkServiceDisposable _deepLink;
+
+		[SetUp]
+		public void Init()
+		{
+			_safeArea = Substitute.For<ISafeAreaServiceDisposable>();
+			_screenWake = Substitute.For<IScreenWakeService>();
+			_battery = Substitute.For<IBatteryServiceDisposable>();
+			_audioSession = Substitute.For<IIosAudioSessionService>();
+			_permissions = Substitute.For<IPermissionsService>();
+			_att = Substitute.For<IAttService>();
+			_deepLink = Substitute.For<IDeepLinkServiceDisposable>();
+		}
+
+		[Test]
+		public void InjectionCtor_StoresEachChildOnMatchingProperty()
+		{
+			var service = new DeviceService(
+				_safeArea,
+				_screenWake,
+				_battery,
+				_audioSession,
+				_permissions,
+				_att,
+				_deepLink);
+
+			Assert.AreSame(_safeArea, service.SafeArea);
+			Assert.AreSame(_screenWake, service.ScreenWake);
+			Assert.AreSame(_battery, service.Battery);
+			Assert.AreSame(_audioSession, service.AudioSession);
+			Assert.AreSame(_permissions, service.Permissions);
+			Assert.AreSame(_att, service.Att);
+			Assert.AreSame(_deepLink, service.DeepLink);
+		}
+
+		[Test]
+		public void Dispose_DisposesDisposableChildren_OnlyOnce()
+		{
+			var service = new DeviceService(
+				_safeArea,
+				_screenWake,
+				_battery,
+				_audioSession,
+				_permissions,
+				_att,
+				_deepLink);
+
+			service.Dispose();
+
+			_safeArea.Received(1).Dispose();
+			_battery.Received(1).Dispose();
+			_deepLink.Received(1).Dispose();
+		}
+
+		[Test]
+		public void Dispose_NonDisposableChildren_NoThrow()
+		{
+			var nonDisposableSafeArea = Substitute.For<ISafeAreaService>();
+			var nonDisposableBattery = Substitute.For<IBatteryService>();
+			var nonDisposableDeepLink = Substitute.For<IDeepLinkService>();
+
+			var service = new DeviceService(
+				nonDisposableSafeArea,
+				_screenWake,
+				nonDisposableBattery,
+				_audioSession,
+				_permissions,
+				_att,
+				nonDisposableDeepLink);
+
+			Assert.DoesNotThrow(service.Dispose);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/DeviceServiceTest.cs.meta b/Tests/EditMode/Unit/DeviceServiceTest.cs.meta
new file mode 100644
index 0000000..18e443b
--- /dev/null
+++ b/Tests/EditMode/Unit/DeviceServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 745564a89fd5c402f89ffabd6b0b1f89
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/EditorGameNotificationTest.cs b/Tests/EditMode/Unit/EditorGameNotificationTest.cs
new file mode 100644
index 0000000..918198a
--- /dev/null
+++ b/Tests/EditMode/Unit/EditorGameNotificationTest.cs
@@ -0,0 +1,50 @@
+using System;
+using GameLovers.MobileServices.Notifications;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class EditorGameNotificationTest
+	{
+		[Test]
+		public void AllProperties_RoundTripGettersAndSetters()
+		{
+			var deliveryTime = new DateTime(2030, 1, 2, 3, 4, 5);
+			var notification = new EditorGameNotification
+			{
+				Id = 42,
+				Title = "Title",
+				Body = "Body",
+				Subtitle = "Sub",
+				Channel = "channel",
+				BadgeNumber = 7,
+				ShouldAutoCancel = true,
+				DeliveryTime = deliveryTime,
+				SmallIcon = "small",
+				LargeIcon = "large",
+			};
+
+			Assert.AreEqual(42, notification.Id);
+			Assert.AreEqual("Title", notification.Title);
+			Assert.AreEqual("Body", notification.Body);
+			Assert.AreEqual("Sub", notification.Subtitle);
+			Assert.AreEqual("channel", notification.Channel);
+			Assert.AreEqual(7, notification.BadgeNumber);
+			Assert.IsTrue(notification.ShouldAutoCancel);
+			Assert.AreEqual(deliveryTime, notification.DeliveryTime);
+			Assert.AreEqual("small", notification.SmallIcon);
+			Assert.AreEqual("large", notification.LargeIcon);
+		}
+
+		[Test]
+		public void Scheduled_DefaultsFalse()
+		{
+			var notification = new EditorGameNotification();
+
+			Assert.IsFalse(notification.Scheduled);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta b/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta
new file mode 100644
index 0000000..53c0174
--- /dev/null
+++ b/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 46a10b2ebbcf243ee98cf702acc74148
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/EditorHapticsBackendTest.cs b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs
new file mode 100644
index 0000000..c888c41
--- /dev/null
+++ b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs
@@ -0,0 +1,56 @@
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class EditorHapticsBackendTest
+	{
+		private EditorHapticsBackend _backend;
+
+		[SetUp]
+		public void Init()
+		{
+			_backend = new EditorHapticsBackend();
+		}
+
+		[Test]
+		public void IsSupported_AlwaysFalse()
+		{
+			Assert.IsFalse(_backend.IsSupported);
+		}
+
+		[Test]
+		public void PlayPresetOneShot_LogsExpected()
+		{
+			LogAssert.Expect(LogType.Log, "[Haptics] PlayPresetOneShot(Selection)");
+			_backend.PlayPresetOneShot(HapticPreset.Selection);
+		}
+
+		[Test]
+		public void PlayPresetLoop_LogsExpected()
+		{
+			LogAssert.Expect(LogType.Log, "[Haptics] PlayPresetLoop(Warning)");
+			_backend.PlayPresetLoop(HapticPreset.Warning);
+		}
+
+		[Test]
+		public void PlayCustom_LogsExpected()
+		{
+			LogAssert.Expect(LogType.Log, "[Haptics] PlayCustom(intensity=0.42, durationMs=250)");
+			_backend.PlayCustom(0.42f, 250f);
+		}
+
+		[Test]
+		public void Stop_LogsExpected()
+		{
+			LogAssert.Expect(LogType.Log, "[Haptics] Stop");
+			_backend.Stop();
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta
new file mode 100644
index 0000000..9f3cdbe
--- /dev/null
+++ b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ca5d3cefd72ac439cafa9bd1b6801dcc
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/GameNotificationChannelTest.cs b/Tests/EditMode/Unit/GameNotificationChannelTest.cs
new file mode 100644
index 0000000..0af2f3a
--- /dev/null
+++ b/Tests/EditMode/Unit/GameNotificationChannelTest.cs
@@ -0,0 +1,88 @@
+using GameLovers.MobileServices.Notifications;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class GameNotificationChannelTest
+	{
+		[Test]
+		public void Ctor_ThreeArg_AppliesDefaults()
+		{
+			var channel = new GameNotificationChannel("id", "name", "description");
+
+			Assert.AreEqual("id", channel.Id);
+			Assert.AreEqual("name", channel.Name);
+			Assert.AreEqual("description", channel.Description);
+			Assert.IsTrue(channel.ShowsBadge);
+			Assert.IsFalse(channel.ShowLights);
+			Assert.IsTrue(channel.Vibrates);
+			Assert.IsFalse(channel.HighPriority);
+			Assert.AreEqual(GameNotificationChannel.NotificationStyle.Popup, channel.Style);
+			Assert.AreEqual(GameNotificationChannel.PrivacyMode.Public, channel.Privacy);
+			Assert.IsNull(channel.VibrationPattern);
+		}
+
+		[Test]
+		public void Ctor_FullArg_StoresAllFields()
+		{
+			var pattern = new long[] { 100L, 200L, 300L };
+
+			var channel = new GameNotificationChannel(
+				"id",
+				"name",
+				"description",
+				GameNotificationChannel.NotificationStyle.NoSound,
+				showsBadge: false,
+				showLights: true,
+				vibrates: false,
+				highPriority: true,
+				privacy: GameNotificationChannel.PrivacyMode.Secret,
+				vibrationPattern: pattern);
+
+			Assert.AreEqual("id", channel.Id);
+			Assert.AreEqual("name", channel.Name);
+			Assert.AreEqual("description", channel.Description);
+			Assert.IsFalse(channel.ShowsBadge);
+			Assert.IsTrue(channel.ShowLights);
+			Assert.IsFalse(channel.Vibrates);
+			Assert.IsTrue(channel.HighPriority);
+			Assert.AreEqual(GameNotificationChannel.NotificationStyle.NoSound, channel.Style);
+			Assert.AreEqual(GameNotificationChannel.PrivacyMode.Secret, channel.Privacy);
+		}
+
+		[Test]
+		public void Ctor_FullArg_VibrationPattern_IntCastFromLongArray()
+		{
+			var pattern = new long[] { 100L, 200L, 300L };
+
+			var channel = new GameNotificationChannel(
+				"id",
+				"name",
+				"description",
+				GameNotificationChannel.NotificationStyle.Default,
+				vibrationPattern: pattern);
+
+			Assert.IsNotNull(channel.VibrationPattern);
+			Assert.AreEqual(3, channel.VibrationPattern.Length);
+			Assert.AreEqual(100, channel.VibrationPattern[0]);
+			Assert.AreEqual(200, channel.VibrationPattern[1]);
+			Assert.AreEqual(300, channel.VibrationPattern[2]);
+		}
+
+		[Test]
+		public void Ctor_FullArg_NullVibrationPattern_PropertyIsNull()
+		{
+			var channel = new GameNotificationChannel(
+				"id",
+				"name",
+				"description",
+				GameNotificationChannel.NotificationStyle.Default,
+				vibrationPattern: null);
+
+			Assert.IsNull(channel.VibrationPattern);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta b/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta
new file mode 100644
index 0000000..45ab3c7
--- /dev/null
+++ b/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3dc398a37bcf44fb19c50f48f97ee304
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/HapticEnvelopesTest.cs b/Tests/EditMode/Unit/HapticEnvelopesTest.cs
new file mode 100644
index 0000000..89430e8
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticEnvelopesTest.cs
@@ -0,0 +1,92 @@
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	/// <summary>
+	/// Pins the per-preset envelope tables that the Device Simulator panel's haptic envelope graph
+	/// and the runtime <see cref="AndroidHapticsBackend"/> both read from. Asserting on shape +
+	/// expected first-row values catches accidental drift if someone edits the float tables.
+	/// </summary>
+	public class HapticEnvelopesTest
+	{
+		[Test]
+		public void GetFloatEnvelope_None_ReturnsSingleZeroPair()
+		{
+			var (timings, amps) = HapticEnvelopes.GetFloatEnvelopeFor(HapticPreset.None);
+			Assert.AreEqual(1, timings.Length);
+			Assert.AreEqual(1, amps.Length);
+			Assert.AreEqual(0f, timings[0]);
+			Assert.AreEqual(0f, amps[0]);
+		}
+
+		[Test]
+		public void GetFloatEnvelope_Selection_MatchesAndroidBackendTable()
+		{
+			var (timings, amps) = HapticEnvelopes.GetFloatEnvelopeFor(HapticPreset.Selection);
+			Assert.AreEqual(1, timings.Length);
+			Assert.AreEqual(0.04f, timings[0], 1e-6f);
+			Assert.AreEqual(0.471f, amps[0], 1e-6f);
+		}
+
+		[Test]
+		public void GetFloatEnvelope_Error_HasSevenSamples()
+		{
+			var (timings, amps) = HapticEnvelopes.GetFloatEnvelopeFor(HapticPreset.Error);
+			Assert.AreEqual(7, timings.Length);
+			Assert.AreEqual(7, amps.Length);
+		}
+
+		[Test]
+		public void GetEnvelope_ConvertsToMillisecondsAnd0to255Amplitudes()
+		{
+			var (timingsMs, amplitudes) = HapticEnvelopes.GetEnvelopeFor(HapticPreset.Selection);
+			Assert.AreEqual(1, timingsMs.Length);
+			Assert.AreEqual(40L, timingsMs[0], "0.04s = 40ms");
+			Assert.AreEqual(120, amplitudes[0], "0.471 * 255 = 120 (rounded).");
+		}
+
+		[Test]
+		public void GetNaturalDurationSeconds_SumsEveryStep()
+		{
+			Assert.AreEqual(0.04f, HapticEnvelopes.GetNaturalDurationSeconds(HapticPreset.Selection), 1e-6f);
+			// Success: 0.04 + 0.04 + 0.16
+			Assert.AreEqual(0.24f, HapticEnvelopes.GetNaturalDurationSeconds(HapticPreset.Success), 1e-6f);
+		}
+
+		[TestCase(HapticPreset.Selection)]
+		[TestCase(HapticPreset.Success)]
+		[TestCase(HapticPreset.Warning)]
+		[TestCase(HapticPreset.Error)]
+		[TestCase(HapticPreset.ImpactLight)]
+		[TestCase(HapticPreset.ImpactMedium)]
+		[TestCase(HapticPreset.ImpactHeavy)]
+		[TestCase(HapticPreset.ImpactRigid)]
+		[TestCase(HapticPreset.ImpactSoft)]
+		public void GetFloatEnvelope_TimingsAndAmpsHaveSameLength(HapticPreset preset)
+		{
+			var (timings, amps) = HapticEnvelopes.GetFloatEnvelopeFor(preset);
+			Assert.AreEqual(timings.Length, amps.Length, $"{preset}: timings/amps length mismatch.");
+		}
+
+		[TestCase(HapticPreset.Selection)]
+		[TestCase(HapticPreset.Success)]
+		[TestCase(HapticPreset.Warning)]
+		[TestCase(HapticPreset.Error)]
+		[TestCase(HapticPreset.ImpactLight)]
+		[TestCase(HapticPreset.ImpactMedium)]
+		[TestCase(HapticPreset.ImpactHeavy)]
+		[TestCase(HapticPreset.ImpactRigid)]
+		[TestCase(HapticPreset.ImpactSoft)]
+		public void GetFloatEnvelope_AmpsAreClamped01(HapticPreset preset)
+		{
+			var (_, amps) = HapticEnvelopes.GetFloatEnvelopeFor(preset);
+			foreach (var amp in amps)
+			{
+				Assert.IsTrue(amp >= 0f && amp <= 1f, $"{preset}: amplitude {amp} outside [0, 1].");
+			}
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/HapticEnvelopesTest.cs.meta b/Tests/EditMode/Unit/HapticEnvelopesTest.cs.meta
new file mode 100644
index 0000000..8257c07
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticEnvelopesTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7cca39b90d1c840c49674c3f18641533
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/HapticPresetTest.cs b/Tests/EditMode/Unit/HapticPresetTest.cs
new file mode 100644
index 0000000..ba2a5ef
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticPresetTest.cs
@@ -0,0 +1,17 @@
+using GameLovers.MobileServices.Haptics;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class HapticPresetTest
+	{
+		[Test]
+		public void None_IsZero()
+		{
+			Assert.AreEqual(0, (int) HapticPreset.None);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/HapticPresetTest.cs.meta b/Tests/EditMode/Unit/HapticPresetTest.cs.meta
new file mode 100644
index 0000000..347ec0c
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticPresetTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cfef388b254b5474eaa5d27cdeebd273
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/HapticsServiceTest.cs b/Tests/EditMode/Unit/HapticsServiceTest.cs
new file mode 100644
index 0000000..0667583
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticsServiceTest.cs
@@ -0,0 +1,215 @@
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class HapticsServiceTest
+	{
+		private FakeHapticsBackend _backend;
+		private HapticsService _haptics;
+
+		[SetUp]
+		public void Init()
+		{
+			_backend = new FakeHapticsBackend { IsSupportedValue = true };
+			_haptics = new HapticsService(_backend);
+		}
+
+		[Test]
+		public void Enabled_DefaultIsTrue()
+		{
+			Assert.IsTrue(_haptics.Enabled);
+		}
+
+		[Test]
+		public void Enabled_SetSameValue_DoesNothing()
+		{
+			_haptics.PlayPreset(HapticPreset.Selection);
+			_backend.Reset();
+
+			_haptics.Enabled = true;
+
+			Assert.AreEqual(0, _backend.StopCount);
+		}
+
+		[Test]
+		public void Enabled_SetFalseWhilePlaying_StopsBackend()
+		{
+			_haptics.PlayPreset(HapticPreset.Selection);
+			_backend.Reset();
+
+			_haptics.Enabled = false;
+
+			Assert.IsFalse(_haptics.Enabled);
+			Assert.AreEqual(1, _backend.StopCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void IsSupported_DelegatesToBackend()
+		{
+			_backend.IsSupportedValue = false;
+			Assert.IsFalse(_haptics.IsSupported);
+
+			_backend.IsSupportedValue = true;
+			Assert.IsTrue(_haptics.IsSupported);
+		}
+
+		[Test]
+		public void IsPlaying_FalseInitially()
+		{
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void DefaultCtor_InEditor_SelectsEditorBackend_NotSupported()
+		{
+			var haptics = new HapticsService();
+
+			Assert.IsTrue(haptics.Enabled);
+			Assert.IsFalse(haptics.IsSupported);
+			Assert.IsFalse(haptics.IsPlaying);
+		}
+
+		[Test]
+		public void PlayPreset_None_NoBackendCall()
+		{
+			_haptics.PlayPreset(HapticPreset.None);
+
+			Assert.AreEqual(0, _backend.OneShotCount);
+			Assert.AreEqual(0, _backend.LoopCount);
+			Assert.AreEqual(0, _backend.CustomCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void PlayPreset_Disabled_NoBackendCall()
+		{
+			_haptics.Enabled = false;
+			_backend.Reset();
+
+			_haptics.PlayPreset(HapticPreset.Selection);
+
+			Assert.AreEqual(0, _backend.OneShotCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void PlayPreset_Natural_CallsOneShotAndIsPlayingTrue()
+		{
+			_haptics.PlayPreset(HapticPreset.Selection);
+
+			Assert.AreEqual(1, _backend.OneShotCount);
+			Assert.AreEqual(HapticPreset.Selection, _backend.LastPreset);
+			Assert.IsTrue(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void PlayPresetDuration_Zero_CallsOneShot()
+		{
+			_haptics.PlayPresetDuration(HapticPreset.Success, 0f);
+
+			Assert.AreEqual(1, _backend.OneShotCount);
+			Assert.AreEqual(0, _backend.LoopCount);
+		}
+
+		[Test]
+		public void PlayPresetDuration_NegativeOrDefault_CallsLoopNoAutoStop()
+		{
+			_haptics.PlayPresetDuration(HapticPreset.Warning);
+
+			Assert.AreEqual(1, _backend.LoopCount);
+			Assert.AreEqual(HapticPreset.Warning, _backend.LastPreset);
+			Assert.IsTrue(_haptics.IsPlaying);
+		}
+
+		[Test]
+		public void PlayCustom_NonPositiveDuration_NoOp()
+		{
+			// Stays in EditMode because the no-op path short-circuits BEFORE EnsureHost()
+			// is called — no DontDestroyOnLoad attempt.
+			_haptics.PlayCustom(0.5f, 0f);
+			_haptics.PlayCustom(0.5f, -10f);
+
+			Assert.AreEqual(0, _backend.CustomCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		// PlayCustom_ClampsIntensity01 lives in HapticsServicePlayModeTest because PlayCustom with
+		// a positive durationMs always spawns the HapticsHost (DontDestroyOnLoad), which is
+		// illegal in EditMode.
+
+		[Test]
+		public void StopCurrentHaptic_NotPlaying_DoesNotCallBackendStop()
+		{
+			_haptics.StopCurrentHaptic();
+
+			Assert.AreEqual(0, _backend.StopCount);
+		}
+
+		[Test]
+		public void StopCurrentHaptic_WhilePlaying_CallsBackendStopAndClearsState()
+		{
+			_haptics.PlayPreset(HapticPreset.Error);
+			_backend.Reset();
+
+			_haptics.StopCurrentHaptic();
+
+			Assert.AreEqual(1, _backend.StopCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+		}
+
+		private sealed class FakeHapticsBackend : IHapticsBackend
+		{
+			public bool IsSupportedValue;
+			public int OneShotCount;
+			public int LoopCount;
+			public int CustomCount;
+			public int StopCount;
+			public HapticPreset LastPreset;
+			public float LastIntensity;
+			public float LastDurationMs;
+
+			public bool IsSupported => IsSupportedValue;
+
+			public void PlayPresetOneShot(HapticPreset preset)
+			{
+				OneShotCount++;
+				LastPreset = preset;
+			}
+
+			public void PlayPresetLoop(HapticPreset preset)
+			{
+				LoopCount++;
+				LastPreset = preset;
+			}
+
+			public void PlayCustom(float intensity01, float durationMs)
+			{
+				CustomCount++;
+				LastIntensity = intensity01;
+				LastDurationMs = durationMs;
+			}
+
+			public void Stop()
+			{
+				StopCount++;
+			}
+
+			public void Reset()
+			{
+				OneShotCount = 0;
+				LoopCount = 0;
+				CustomCount = 0;
+				StopCount = 0;
+				LastPreset = HapticPreset.None;
+				LastIntensity = 0f;
+				LastDurationMs = 0f;
+			}
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/HapticsServiceTest.cs.meta b/Tests/EditMode/Unit/HapticsServiceTest.cs.meta
new file mode 100644
index 0000000..0418e88
--- /dev/null
+++ b/Tests/EditMode/Unit/HapticsServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 529c90f671f454ca5ad36f69525f7030
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs
new file mode 100644
index 0000000..35622f6
--- /dev/null
+++ b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs
@@ -0,0 +1,24 @@
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class IosAudioSessionServiceTest
+	{
+		[Test]
+		public void ConfigureForPlayback_InEditor_LogsAndDoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log,
+				"[GameLovers.MobileServices] IosAudioSessionService.ConfigureForPlayback skipped (not running on iOS device)");
+
+			var service = new IosAudioSessionService();
+
+			Assert.DoesNotThrow(service.ConfigureForPlayback);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta
new file mode 100644
index 0000000..c9c7700
--- /dev/null
+++ b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 35f7238a9a64c4b39bbe8c7d639331a7
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/MobileServiceTest.cs b/Tests/EditMode/Unit/MobileServiceTest.cs
new file mode 100644
index 0000000..1f129dd
--- /dev/null
+++ b/Tests/EditMode/Unit/MobileServiceTest.cs
@@ -0,0 +1,41 @@
+using GameLovers.MobileServices;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.NativeUi;
+using GameLovers.MobileServices.Notifications;
+using NSubstitute;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class MobileServiceTest
+	{
+		[Test]
+		public void InjectionCtor_StoresEachChild()
+		{
+			var nativeUi = Substitute.For<INativeUiService>();
+			var notifications = Substitute.For<INotificationService>();
+			var haptics = Substitute.For<IHapticsService>();
+			var device = Substitute.For<IDeviceService>();
+
+			var mobile = new MobileService(nativeUi, notifications, haptics, device);
+
+			Assert.AreSame(nativeUi, mobile.NativeUi);
+			Assert.AreSame(notifications, mobile.Notifications);
+			Assert.AreSame(haptics, mobile.Haptics);
+			Assert.AreSame(device, mobile.Device);
+		}
+
+		[Test]
+		public void Dispose_DoesNotThrow_WhenDeviceIsNotDisposable()
+		{
+			var mobile = new MobileService(
+				Substitute.For<INativeUiService>(),
+				Substitute.For<INotificationService>(),
+				Substitute.For<IHapticsService>(),
+				Substitute.For<IDeviceService>());
+			Assert.DoesNotThrow(mobile.Dispose);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/MobileServiceTest.cs.meta b/Tests/EditMode/Unit/MobileServiceTest.cs.meta
new file mode 100644
index 0000000..09788fb
--- /dev/null
+++ b/Tests/EditMode/Unit/MobileServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0cbebd230b7ab4bd798444192e180afa
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/MultiPermissionRequestTest.cs b/Tests/EditMode/Unit/MultiPermissionRequestTest.cs
new file mode 100644
index 0000000..103f2fe
--- /dev/null
+++ b/Tests/EditMode/Unit/MultiPermissionRequestTest.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class MultiPermissionRequestTest
+	{
+		[Test]
+		public async Task RequestAsync_MultiplePermissions_AggregatesIntoDictionary()
+		{
+			IPermissionsService service = new PermissionsService();
+			var result = await service.RequestAsync(AppPermission.Camera, AppPermission.Microphone, AppPermission.Notifications);
+
+			Assert.AreEqual(3, result.Count);
+			Assert.IsTrue(result.ContainsKey(AppPermission.Camera));
+			Assert.IsTrue(result.ContainsKey(AppPermission.Microphone));
+			Assert.IsTrue(result.ContainsKey(AppPermission.Notifications));
+		}
+
+		[Test]
+		public async Task RequestAsync_NoPermissions_ReturnsEmptyDictionary()
+		{
+			IPermissionsService service = new PermissionsService();
+			var result = await service.RequestAsync(new AppPermission[0]);
+			Assert.AreEqual(0, result.Count);
+		}
+
+		[Test]
+		public async Task RequestAsync_Null_ReturnsEmptyDictionary()
+		{
+			IPermissionsService service = new PermissionsService();
+			var result = await service.RequestAsync((AppPermission[])null);
+			Assert.AreEqual(0, result.Count);
+		}
+
+		[Test]
+		public async Task RequestAsync_DuplicatePermission_LastValueWins()
+		{
+			IPermissionsService service = new PermissionsService();
+			// The aggregated dictionary deduplicates by key — sequential awaits write the same key twice.
+			var result = await service.RequestAsync(AppPermission.Camera, AppPermission.Camera);
+			Assert.AreEqual(1, result.Count);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/MultiPermissionRequestTest.cs.meta b/Tests/EditMode/Unit/MultiPermissionRequestTest.cs.meta
new file mode 100644
index 0000000..64242d9
--- /dev/null
+++ b/Tests/EditMode/Unit/MultiPermissionRequestTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 260d37610d40442ea9385a2ce5ac7d00
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs
new file mode 100644
index 0000000..b83ac1f
--- /dev/null
+++ b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs
@@ -0,0 +1,49 @@
+using GameLovers.MobileServices.NativeUi;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class NativeUiServiceInstanceTest
+	{
+		private NativeUiServiceInstance _instance;
+
+		[SetUp]
+		public void Init()
+		{
+			_instance = new NativeUiServiceInstance();
+		}
+
+		[Test]
+		public void ImplementsInterface()
+		{
+			Assert.IsInstanceOf<INativeUiService>(_instance);
+		}
+
+		[Test]
+		public void ShowAlertPopUp_DoesNotThrowInEditor()
+		{
+			Assert.DoesNotThrow(() => _instance.ShowAlertPopUp(
+				false, "T", "M",
+				new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }));
+		}
+
+		[Test]
+		public void ShowToastMessage_DoesNotThrowInEditor()
+		{
+			Assert.DoesNotThrow(() => _instance.ShowToastMessage("hi", false));
+		}
+
+		[Test]
+		public void RequestReview_DoesNotThrowInEditor()
+		{
+			Assert.DoesNotThrow(() => _instance.RequestReview());
+		}
+
+		[Test]
+		public void Share_DoesNotThrowInEditor()
+		{
+			Assert.DoesNotThrow(() => _instance.Share("text", "url"));
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs.meta b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs.meta
new file mode 100644
index 0000000..b612571
--- /dev/null
+++ b/Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 337610d96ffa04953afdc9b42cf25e95
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs b/Tests/EditMode/Unit/NativeUiServiceTest.cs
new file mode 100644
index 0000000..3b8866f
--- /dev/null
+++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs
@@ -0,0 +1,77 @@
+using System;
+using GameLovers.MobileServices.NativeUi;
+using NUnit.Framework;
+using UnityEngine.TestTools;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class NativeUiServiceTest
+	{
+		[Test]
+		public void ShowAlertPopUp_InEditor_LogsAndDoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log, "Show Alert Pop Up is not available in the editor and was triggered with: T - M");
+
+			Assert.DoesNotThrow(() =>
+			{
+				NativeUiService.ShowAlertPopUp(
+					false,
+					"T",
+					"M",
+					new AlertButton { Text = "OK", Style = AlertButtonStyle.Default });
+			});
+		}
+
+		[Test]
+		public void ShowToastMessage_InEditor_LogsAndDoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log, "Show Toast message is not available in the editor and was triggered with: hello");
+
+			Assert.DoesNotThrow(() => NativeUiService.ShowToastMessage("hello", false));
+		}
+
+		[Test]
+		public void RequestReview_InEditor_LogsAndDoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log, "[GameLovers.MobileServices] RequestReview() is a no-op in the editor unless the Mobile Services Device Simulator is enabled.");
+
+			Assert.DoesNotThrow(NativeUiService.RequestReview);
+		}
+
+		[Test]
+		public void Share_InEditor_LogsAndDoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log, "Share is not available in the editor (text='hello', url='https://example.com', imagePath='').");
+
+			Assert.DoesNotThrow(() => NativeUiService.Share("hello", "https://example.com"));
+		}
+
+		[Test]
+		public void Share_NullOptionalArgs_InEditor_DoesNotThrow()
+		{
+			LogAssert.Expect(LogType.Log, "Share is not available in the editor (text='hi', url='', imagePath='').");
+
+			Assert.DoesNotThrow(() => NativeUiService.Share("hi"));
+		}
+
+		[Test]
+		public void AlertButton_FieldRoundTrip()
+		{
+			Action callback = () => { };
+			var button = new AlertButton
+			{
+				Text = "Cancel",
+				Style = AlertButtonStyle.Cancel,
+				Callback = callback,
+			};
+
+			Assert.AreEqual("Cancel", button.Text);
+			Assert.AreEqual(AlertButtonStyle.Cancel, button.Style);
+			Assert.AreSame(callback, button.Callback);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta b/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta
new file mode 100644
index 0000000..170ccb3
--- /dev/null
+++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fc2995d2cea264d09bc39eb797538320
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs
new file mode 100644
index 0000000..58660ba
--- /dev/null
+++ b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs
@@ -0,0 +1,24 @@
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class NoOpHapticsBackendTest
+	{
+		[Test]
+		public void AllMembers_DoNotThrow_AndIsSupportedFalse()
+		{
+			var backend = new NoOpHapticsBackend();
+
+			Assert.IsFalse(backend.IsSupported);
+			Assert.DoesNotThrow(() => backend.PlayPresetOneShot(HapticPreset.Success));
+			Assert.DoesNotThrow(() => backend.PlayPresetLoop(HapticPreset.Selection));
+			Assert.DoesNotThrow(() => backend.PlayCustom(0.5f, 100f));
+			Assert.DoesNotThrow(backend.Stop);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta
new file mode 100644
index 0000000..b1a2bf2
--- /dev/null
+++ b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 709296d7446a3458f8b7244f3c714a3a
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/NotificationBuilderTest.cs b/Tests/EditMode/Unit/NotificationBuilderTest.cs
new file mode 100644
index 0000000..bcc01a8
--- /dev/null
+++ b/Tests/EditMode/Unit/NotificationBuilderTest.cs
@@ -0,0 +1,108 @@
+using System;
+using GameLovers.MobileServices.Notifications;
+using NSubstitute;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class NotificationBuilderTest
+	{
+		private INotificationService _service;
+		private IGameNotification _stub;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = Substitute.For<INotificationService>();
+			_stub = new StubNotification();
+			_service.CreateNotification().Returns(_stub);
+			_service.ScheduleNotification(Arg.Any<IGameNotification>())
+				.Returns(call => new PendingNotification(call.Arg<IGameNotification>()));
+		}
+
+		[Test]
+		public void Schedule_ReturnsNewBuilder()
+		{
+			var builder = _service.Schedule();
+			Assert.IsNotNull(builder);
+			_service.Received(1).CreateNotification();
+		}
+
+		[Test]
+		public void Builder_TitleBodyChannel_AssignsAllFields()
+		{
+			_service.Schedule().Title("T").Body("B").Channel("c").Send();
+			Assert.AreEqual("T", _stub.Title);
+			Assert.AreEqual("B", _stub.Body);
+			Assert.AreEqual("c", _stub.Channel);
+		}
+
+		[Test]
+		public void Builder_SubtitleIdSmallIconLargeIcon_AssignsAllFields()
+		{
+			_service.Schedule().Subtitle("S").Id(42).SmallIcon("sm").LargeIcon("lg").Send();
+			Assert.AreEqual("S", _stub.Subtitle);
+			Assert.AreEqual(42, _stub.Id);
+			Assert.AreEqual("sm", _stub.SmallIcon);
+			Assert.AreEqual("lg", _stub.LargeIcon);
+		}
+
+		[Test]
+		public void In_AssignsDeliveryTimeRelativeToNow()
+		{
+			var before = DateTime.Now;
+			_service.Schedule().In(TimeSpan.FromHours(1)).Send();
+			var after = DateTime.Now;
+			Assert.IsTrue(_stub.DeliveryTime.HasValue);
+			Assert.IsTrue(_stub.DeliveryTime.Value >= before.AddHours(1));
+			Assert.IsTrue(_stub.DeliveryTime.Value <= after.AddHours(1).AddSeconds(1));
+		}
+
+		[Test]
+		public void At_AssignsExactDeliveryTime()
+		{
+			var target = new DateTime(2030, 1, 1, 12, 0, 0);
+			_service.Schedule().At(target).Send();
+			Assert.AreEqual(target, _stub.DeliveryTime);
+		}
+
+		[Test]
+		public void BadgeIncrement_ClearsBadgeNumber()
+		{
+			_service.Schedule().BadgeNumber(7).BadgeIncrement().Send();
+			Assert.IsFalse(_stub.BadgeNumber.HasValue);
+		}
+
+		[Test]
+		public void AutoCancel_DefaultTrue_AssignsTrue()
+		{
+			_service.Schedule().AutoCancel().Send();
+			Assert.IsTrue(_stub.ShouldAutoCancel);
+		}
+
+		[Test]
+		public void Send_CallsScheduleAndReturnsPending()
+		{
+			var pending = _service.Schedule().Title("X").Send();
+			Assert.IsNotNull(pending);
+			Assert.AreSame(_stub, pending.Notification);
+			_service.Received(1).ScheduleNotification(_stub);
+		}
+
+		private sealed class StubNotification : IGameNotification
+		{
+			public int? Id { get; set; }
+			public string Title { get; set; }
+			public string Body { get; set; }
+			public string Subtitle { get; set; }
+			public string Channel { get; set; }
+			public int? BadgeNumber { get; set; }
+			public bool ShouldAutoCancel { get; set; }
+			public DateTime? DeliveryTime { get; set; }
+			public bool Scheduled => false;
+			public string SmallIcon { get; set; }
+			public string LargeIcon { get; set; }
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/NotificationBuilderTest.cs.meta b/Tests/EditMode/Unit/NotificationBuilderTest.cs.meta
new file mode 100644
index 0000000..29e5f4a
--- /dev/null
+++ b/Tests/EditMode/Unit/NotificationBuilderTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 836c01361e83b4c4e8da7c38edd3b824
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/OperatingModeTest.cs b/Tests/EditMode/Unit/OperatingModeTest.cs
new file mode 100644
index 0000000..364ec6f
--- /dev/null
+++ b/Tests/EditMode/Unit/OperatingModeTest.cs
@@ -0,0 +1,31 @@
+using GameLovers.MobileServices.Notifications;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class OperatingModeTest
+	{
+		[Test]
+		public void QueueAndClear_HasQueueAndClearOnForegroundingFlags()
+		{
+			var mode = OperatingMode.QueueAndClear;
+
+			Assert.IsTrue((mode & OperatingMode.Queue) == OperatingMode.Queue);
+			Assert.IsTrue((mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding);
+			Assert.IsFalse((mode & OperatingMode.RescheduleAfterClearing) == OperatingMode.RescheduleAfterClearing);
+		}
+
+		[Test]
+		public void QueueClearAndReschedule_HasAllThreeFlags()
+		{
+			var mode = OperatingMode.QueueClearAndReschedule;
+
+			Assert.IsTrue((mode & OperatingMode.Queue) == OperatingMode.Queue);
+			Assert.IsTrue((mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding);
+			Assert.IsTrue((mode & OperatingMode.RescheduleAfterClearing) == OperatingMode.RescheduleAfterClearing);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/OperatingModeTest.cs.meta b/Tests/EditMode/Unit/OperatingModeTest.cs.meta
new file mode 100644
index 0000000..efc5c32
--- /dev/null
+++ b/Tests/EditMode/Unit/OperatingModeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 88c514f3b3fb34b6a9a50c5015071f1c
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/PendingNotificationTest.cs b/Tests/EditMode/Unit/PendingNotificationTest.cs
new file mode 100644
index 0000000..905e0eb
--- /dev/null
+++ b/Tests/EditMode/Unit/PendingNotificationTest.cs
@@ -0,0 +1,36 @@
+using System;
+using GameLovers.MobileServices.Notifications;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class PendingNotificationTest
+	{
+		[Test]
+		public void Ctor_NullNotification_ThrowsArgumentNullException()
+		{
+			Assert.Throws<ArgumentNullException>(() => new PendingNotification(null));
+		}
+
+		[Test]
+		public void Ctor_StoresNotificationReference()
+		{
+			var notification = new EditorGameNotification { Title = "ref" };
+
+			var pending = new PendingNotification(notification);
+
+			Assert.AreSame(notification, pending.Notification);
+		}
+
+		[Test]
+		public void Reschedule_DefaultsFalse()
+		{
+			var pending = new PendingNotification(new EditorGameNotification());
+
+			Assert.IsFalse(pending.Reschedule);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/PendingNotificationTest.cs.meta b/Tests/EditMode/Unit/PendingNotificationTest.cs.meta
new file mode 100644
index 0000000..8bda138
--- /dev/null
+++ b/Tests/EditMode/Unit/PendingNotificationTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9caa3338e21ea406a80d301b01ee0a02
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/PermissionsServiceTest.cs b/Tests/EditMode/Unit/PermissionsServiceTest.cs
new file mode 100644
index 0000000..664546b
--- /dev/null
+++ b/Tests/EditMode/Unit/PermissionsServiceTest.cs
@@ -0,0 +1,49 @@
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class PermissionsServiceTest
+	{
+		private PermissionsService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new PermissionsService();
+		}
+
+		[Test]
+		[TestCase(AppPermission.Camera)]
+		[TestCase(AppPermission.Microphone)]
+		[TestCase(AppPermission.LocationWhenInUse)]
+		[TestCase(AppPermission.LocationAlways)]
+		[TestCase(AppPermission.PhotoLibrary)]
+		[TestCase(AppPermission.PhotoLibraryAddOnly)]
+		[TestCase(AppPermission.Notifications)]
+		public void Check_InEditor_AllPermissions_ReturnsGranted(AppPermission permission)
+		{
+			Assert.AreEqual(PermissionStatus.Granted, _service.Check(permission));
+		}
+
+		[Test]
+		[TestCase(AppPermission.Camera)]
+		[TestCase(AppPermission.Microphone)]
+		[TestCase(AppPermission.LocationWhenInUse)]
+		[TestCase(AppPermission.LocationAlways)]
+		[TestCase(AppPermission.PhotoLibrary)]
+		[TestCase(AppPermission.PhotoLibraryAddOnly)]
+		[TestCase(AppPermission.Notifications)]
+		public void RequestAsync_InEditor_AllPermissions_ReturnsGranted(AppPermission permission)
+		{
+			Task<PermissionStatus> task = _service.RequestAsync(permission);
+
+			Assert.IsTrue(task.IsCompleted);
+			Assert.AreEqual(PermissionStatus.Granted, task.Result);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta b/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta
new file mode 100644
index 0000000..af510fb
--- /dev/null
+++ b/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7c93769ea53b14803bcfe00235f93cb5
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/SafeAreaContainerTest.cs b/Tests/EditMode/Unit/SafeAreaContainerTest.cs
new file mode 100644
index 0000000..332a9f3
--- /dev/null
+++ b/Tests/EditMode/Unit/SafeAreaContainerTest.cs
@@ -0,0 +1,91 @@
+using System;
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class SafeAreaContainerTest
+	{
+		[Test]
+		public void SetSafeAreaService_AppliesPaddingFromCurrentSafeArea()
+		{
+			var fake = new FakeSafeAreaService(new Rect(10f, 20f, Screen.width - 30f, Screen.height - 50f));
+			var container = new SafeAreaContainer();
+
+			container.SetSafeAreaService(fake);
+
+			AssertPaddingMatches(container, fake.SafeArea);
+		}
+
+		[Test]
+		public void SetSafeAreaService_OnSafeAreaChanged_UpdatesPadding()
+		{
+			var fake = new FakeSafeAreaService(new Rect(0f, 0f, Screen.width, Screen.height));
+			var container = new SafeAreaContainer(fake);
+
+			var newArea = new Rect(15f, 25f, Screen.width - 40f, Screen.height - 60f);
+			fake.RaiseChanged(newArea);
+
+			AssertPaddingMatches(container, newArea);
+		}
+
+		[Test]
+		public void SetSafeAreaService_Replace_UnsubscribesPreviousService()
+		{
+			var first = new FakeSafeAreaService(new Rect(0f, 0f, Screen.width, Screen.height));
+			var second = new FakeSafeAreaService(new Rect(5f, 5f, Screen.width - 10f, Screen.height - 10f));
+
+			var container = new SafeAreaContainer(first);
+			container.SetSafeAreaService(second);
+
+			Assert.AreEqual(0, first.HandlerCount);
+			Assert.AreEqual(1, second.HandlerCount);
+		}
+
+		private static void AssertPaddingMatches(SafeAreaContainer container, Rect safeArea)
+		{
+			var screenWidth = Screen.width;
+			var screenHeight = Screen.height;
+			if (screenWidth <= 0 || screenHeight <= 0)
+			{
+				Assert.Inconclusive("Screen dimensions are not initialised in this EditMode harness; padding assertion skipped.");
+				return;
+			}
+
+			Assert.AreEqual(safeArea.xMin, container.style.paddingLeft.value.value, 1e-3f);
+			Assert.AreEqual(screenWidth - safeArea.xMax, container.style.paddingRight.value.value, 1e-3f);
+			Assert.AreEqual(screenHeight - safeArea.yMax, container.style.paddingTop.value.value, 1e-3f);
+			Assert.AreEqual(safeArea.yMin, container.style.paddingBottom.value.value, 1e-3f);
+		}
+
+		private sealed class FakeSafeAreaService : ISafeAreaService
+		{
+			public int HandlerCount;
+
+			public FakeSafeAreaService(Rect safeArea)
+			{
+				SafeArea = safeArea;
+			}
+
+			public Rect SafeArea { get; private set; }
+
+			private event Action<Rect> _onChanged;
+
+			public event Action<Rect> OnSafeAreaChanged
+			{
+				add { _onChanged += value; HandlerCount++; }
+				remove { _onChanged -= value; HandlerCount--; }
+			}
+
+			public void RaiseChanged(Rect newArea)
+			{
+				SafeArea = newArea;
+				_onChanged?.Invoke(newArea);
+			}
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta b/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta
new file mode 100644
index 0000000..0caca79
--- /dev/null
+++ b/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7aca5687b73744287bdee5442cc266f7
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/ScreenWakeServiceTest.cs b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs
new file mode 100644
index 0000000..a38f6c7
--- /dev/null
+++ b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs
@@ -0,0 +1,55 @@
+using GameLovers.MobileServices.Device;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class ScreenWakeServiceTest
+	{
+		private ScreenWakeService _service;
+		private int _originalTimeout;
+
+		[SetUp]
+		public void Init()
+		{
+			_originalTimeout = Screen.sleepTimeout;
+			_service = new ScreenWakeService();
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			Screen.sleepTimeout = _originalTimeout;
+		}
+
+		[Test]
+		public void KeepAwake_True_SetsScreenSleepTimeoutNeverSleep()
+		{
+			_service.KeepAwake = true;
+
+			Assert.AreEqual(SleepTimeout.NeverSleep, Screen.sleepTimeout);
+		}
+
+		[Test]
+		public void KeepAwake_False_RestoresSystemSetting()
+		{
+			_service.KeepAwake = true;
+			_service.KeepAwake = false;
+
+			Assert.AreEqual(SleepTimeout.SystemSetting, Screen.sleepTimeout);
+		}
+
+		[Test]
+		public void KeepAwake_Get_ReflectsScreenSleepTimeout()
+		{
+			Screen.sleepTimeout = SleepTimeout.NeverSleep;
+			Assert.IsTrue(_service.KeepAwake);
+
+			Screen.sleepTimeout = SleepTimeout.SystemSetting;
+			Assert.IsFalse(_service.KeepAwake);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta
new file mode 100644
index 0000000..44bb77f
--- /dev/null
+++ b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e7c15149e36b14a5fa31081cdf5d45e3
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/SwipeInputTest.cs b/Tests/EditMode/Unit/SwipeInputTest.cs
new file mode 100644
index 0000000..04f98b9
--- /dev/null
+++ b/Tests/EditMode/Unit/SwipeInputTest.cs
@@ -0,0 +1,50 @@
+using GameLovers.MobileServices.Gestures;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class SwipeInputTest
+	{
+		[Test]
+		public void Ctor_FromGesture_ComputesDirectionFromStartEnd()
+		{
+			var gesture = new ActiveGesture(3, Vector2.zero, 0.0);
+			gesture.SubmitPoint(new Vector2(50f, 0f), 0.5);
+
+			var swipe = new SwipeInput(gesture);
+
+			Assert.AreEqual(3, swipe.InputId);
+			Assert.AreEqual(Vector2.zero, swipe.StartPosition);
+			Assert.AreEqual(new Vector2(50f, 0f), swipe.EndPosition);
+			Assert.AreEqual(Vector2.right, swipe.SwipeDirection);
+			Assert.AreEqual(0.5, swipe.SwipeDuration);
+			Assert.AreEqual(50f, swipe.TravelDistance, 1e-4f);
+		}
+
+		[Test]
+		public void Ctor_FromGesture_ZeroDuration_VelocityRemainsZero()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 1.0);
+			// No SubmitPoint → EndTime == StartTime → SwipeDuration == 0
+			var swipe = new SwipeInput(gesture);
+
+			Assert.AreEqual(0.0, swipe.SwipeDuration);
+			Assert.AreEqual(0f, swipe.SwipeVelocity);
+		}
+
+		[Test]
+		public void Ctor_FromGesture_PositiveDuration_VelocityIsTravelOverDuration()
+		{
+			var gesture = new ActiveGesture(0, Vector2.zero, 0.0);
+			gesture.SubmitPoint(new Vector2(100f, 0f), 0.25);
+
+			var swipe = new SwipeInput(gesture);
+
+			Assert.AreEqual(400f, swipe.SwipeVelocity, 1e-3f);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/SwipeInputTest.cs.meta b/Tests/EditMode/Unit/SwipeInputTest.cs.meta
new file mode 100644
index 0000000..dd45705
--- /dev/null
+++ b/Tests/EditMode/Unit/SwipeInputTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 17ac4e8ce2a874ec59236ac4fab3c1a8
\ No newline at end of file
diff --git a/Tests/EditMode/Unit/TapInputTest.cs b/Tests/EditMode/Unit/TapInputTest.cs
new file mode 100644
index 0000000..15f2a42
--- /dev/null
+++ b/Tests/EditMode/Unit/TapInputTest.cs
@@ -0,0 +1,28 @@
+using GameLovers.MobileServices.Gestures;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	[TestFixture]
+	public class TapInputTest
+	{
+		[Test]
+		public void Ctor_FromGesture_CapturesPositionsDurationDriftAndTimestamp()
+		{
+			var start = new Vector2(10f, 20f);
+			var gesture = new ActiveGesture(0, start, 5.0);
+			gesture.SubmitPoint(new Vector2(12f, 23f), 5.1);
+
+			var tap = new TapInput(gesture);
+
+			Assert.AreEqual(start, tap.PressPosition);
+			Assert.AreEqual(new Vector2(12f, 23f), tap.ReleasePosition);
+			Assert.AreEqual(5.1, tap.TimeStamp);
+			Assert.AreEqual(0.1, tap.TapDuration, 1e-9);
+			Assert.AreEqual(gesture.TravelDistance, tap.TapDrift, 1e-4f);
+		}
+	}
+}
diff --git a/Tests/EditMode/Unit/TapInputTest.cs.meta b/Tests/EditMode/Unit/TapInputTest.cs.meta
new file mode 100644
index 0000000..442f08b
--- /dev/null
+++ b/Tests/EditMode/Unit/TapInputTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8fe6d6bf49beb4d609868414ec7dc61b
\ No newline at end of file
diff --git a/Tests/PlayMode.meta b/Tests/PlayMode.meta
new file mode 100644
index 0000000..e2a6a1a
--- /dev/null
+++ b/Tests/PlayMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1d7efa5dae5f34bef9bb0e5d934e0c4f
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef
new file mode 100644
index 0000000..0cfac2b
--- /dev/null
+++ b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef
@@ -0,0 +1,23 @@
+{
+    "name": "GameLovers.MobileServices.PlayMode.Tests",
+    "rootNamespace": "GameLoversEditor.MobileServices.Tests",
+    "references": [
+        "GameLovers.MobileServices",
+        "UnityEngine.TestRunner",
+        "UnityEditor.TestRunner",
+        "Unity.InputSystem"
+    ],
+    "includePlatforms": [],
+    "excludePlatforms": [],
+    "allowUnsafeCode": false,
+    "overrideReferences": true,
+    "precompiledReferences": [
+        "nunit.framework.dll"
+    ],
+    "autoReferenced": false,
+    "defineConstraints": [
+        "UNITY_INCLUDE_TESTS"
+    ],
+    "versionDefines": [],
+    "noEngineReferences": false
+}
diff --git a/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta
new file mode 100644
index 0000000..2703c74
--- /dev/null
+++ b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: e6be5882e466d4dfcb852f75aa28766d
+AssemblyDefinitionImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/PlayMode/Smoke.meta b/Tests/PlayMode/Smoke.meta
new file mode 100644
index 0000000..c974c16
--- /dev/null
+++ b/Tests/PlayMode/Smoke.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c23a02df8d1214e93ab3ff8eb347222a
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs
new file mode 100644
index 0000000..0581ccc
--- /dev/null
+++ b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs
@@ -0,0 +1,73 @@
+using System.Collections;
+using GameLovers.MobileServices.Gestures;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.InputSystem.EnhancedTouch;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class GestureControllerSmokeTest
+	{
+		private GameObject _go;
+		private GestureController _controller;
+
+		[SetUp]
+		public void Init()
+		{
+			_go = new GameObject("GestureController");
+			_controller = _go.AddComponent<GestureController>();
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			if (_go != null)
+			{
+				Object.Destroy(_go);
+			}
+
+			// EnhancedTouchSupport is enabled in OnEnable; ensure we leave the global state clean.
+			if (EnhancedTouchSupport.enabled)
+			{
+				EnhancedTouchSupport.Disable();
+			}
+		}
+
+		[UnityTest]
+		public IEnumerator OnEnable_EnablesEnhancedTouchSupport_AndOnDisableDisables()
+		{
+			yield return null;
+
+			Assert.IsTrue(EnhancedTouchSupport.enabled,
+				"GestureController.OnEnable should enable EnhancedTouchSupport");
+
+			_controller.enabled = false;
+			yield return null;
+
+			Assert.IsFalse(EnhancedTouchSupport.enabled,
+				"GestureController.OnDisable should disable EnhancedTouchSupport");
+		}
+
+		[UnityTest]
+		public IEnumerator Ctor_EmitsNoEventsBeforeFingerInteraction()
+		{
+			var pressed = 0;
+			var swiped = 0;
+			var tapped = 0;
+
+			_controller.Pressed += _ => pressed++;
+			_controller.Swiped  += _ => swiped++;
+			_controller.Tapped  += _ => tapped++;
+
+			yield return null;
+			yield return null;
+
+			Assert.AreEqual(0, pressed);
+			Assert.AreEqual(0, swiped);
+			Assert.AreEqual(0, tapped);
+		}
+	}
+}
diff --git a/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta
new file mode 100644
index 0000000..f0fa2a8
--- /dev/null
+++ b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0d7563430bbaa4f04aa6308df7ec0e2e
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit.meta b/Tests/PlayMode/Unit.meta
new file mode 100644
index 0000000..109acbd
--- /dev/null
+++ b/Tests/PlayMode/Unit.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e7384eab7d2f541fd99d760821536478
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs
new file mode 100644
index 0000000..20f1952
--- /dev/null
+++ b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs
@@ -0,0 +1,56 @@
+using System.Collections;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class AttCallbackReceiverTest
+	{
+		[TearDown]
+		public void Cleanup()
+		{
+			var go = GameObject.Find("AttCallbackReceiver");
+			if (go != null)
+			{
+				Object.Destroy(go);
+			}
+		}
+
+		[UnityTest]
+		public IEnumerator OnAttResult_ValidPayload_ResolvesPendingTcs()
+		{
+			var receiver = AttCallbackReceiver.Instance;
+			var tcs = new TaskCompletionSource<AttStatus>();
+			var id = receiver.Register(tcs);
+
+			receiver.OnAttResult($"{id}:{(int) AttStatus.Authorized}");
+
+			yield return null;
+
+			Assert.IsTrue(tcs.Task.IsCompleted);
+			Assert.AreEqual(AttStatus.Authorized, tcs.Task.Result);
+		}
+
+		[UnityTest]
+		public IEnumerator OnAttResult_MalformedPayload_DoesNotThrow()
+		{
+			var receiver = AttCallbackReceiver.Instance;
+			var tcs = new TaskCompletionSource<AttStatus>();
+			receiver.Register(tcs);
+
+			Assert.DoesNotThrow(() => receiver.OnAttResult("malformed"));
+			Assert.DoesNotThrow(() => receiver.OnAttResult("1:not-int"));
+			Assert.DoesNotThrow(() => receiver.OnAttResult(":3"));
+
+			yield return null;
+
+			Assert.IsFalse(tcs.Task.IsCompleted);
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta
new file mode 100644
index 0000000..832aaca
--- /dev/null
+++ b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 00d71c524cfe6406da71f61ae9cf0e93
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/BatteryServiceTest.cs b/Tests/PlayMode/Unit/BatteryServiceTest.cs
new file mode 100644
index 0000000..f7266d4
--- /dev/null
+++ b/Tests/PlayMode/Unit/BatteryServiceTest.cs
@@ -0,0 +1,55 @@
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class BatteryServiceTest
+	{
+		private BatteryService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new BatteryService(DeviceServicesHost.Instance);
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_service.Dispose();
+			DeviceServicesHost.ResetForTests();
+		}
+
+		[Test]
+		public void Ctor_CapturesInitialLevelStatusAndLowPowerMode()
+		{
+			Assert.AreEqual(SystemInfo.batteryLevel, _service.Level);
+			Assert.AreEqual(SystemInfo.batteryStatus, _service.Status);
+			// On Editor / unsupported platforms, low-power-mode is reported as false (see BatteryService.QueryLowPowerMode).
+			Assert.IsFalse(_service.IsLowPowerMode);
+		}
+
+		[Test]
+		public void Dispose_UnregistersAllHostHandlers()
+		{
+			Assert.DoesNotThrow(_service.Dispose);
+			Assert.DoesNotThrow(_service.Dispose, "Dispose should be idempotent (subtracting an already-removed handler is a no-op).");
+		}
+
+		[Test]
+		public void DefaultCtor_UsesSharedHost_CapturesInitialState()
+		{
+			var service = new BatteryService();
+
+			Assert.AreEqual(SystemInfo.batteryLevel, service.Level);
+			Assert.AreEqual(SystemInfo.batteryStatus, service.Status);
+			Assert.IsFalse(service.IsLowPowerMode);
+
+			service.Dispose();
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta b/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta
new file mode 100644
index 0000000..f61ecd3
--- /dev/null
+++ b/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e22eeff1510d449328dfa1d79f3d8c25
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs b/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs
new file mode 100644
index 0000000..bdf9c8f
--- /dev/null
+++ b/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs
@@ -0,0 +1,38 @@
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class DeviceServicePlayModeTest
+	{
+		private DeviceService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new DeviceService();
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_service.Dispose();
+			DeviceServicesHost.ResetForTests();
+		}
+
+		[Test]
+		public void DefaultCtor_WiresAllSubServices_NonNull()
+		{
+			Assert.IsNotNull(_service.SafeArea);
+			Assert.IsNotNull(_service.ScreenWake);
+			Assert.IsNotNull(_service.Battery);
+			Assert.IsNotNull(_service.AudioSession);
+			Assert.IsNotNull(_service.Permissions);
+			Assert.IsNotNull(_service.Att);
+			Assert.IsNotNull(_service.DeepLink);
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs.meta b/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs.meta
new file mode 100644
index 0000000..0db5ba4
--- /dev/null
+++ b/Tests/PlayMode/Unit/DeviceServicePlayModeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 989b5cf3f476e47eb810b50262708796
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/DeviceServicesHostTest.cs b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs
new file mode 100644
index 0000000..78205a9
--- /dev/null
+++ b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs
@@ -0,0 +1,111 @@
+using System.Collections;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class DeviceServicesHostTest
+	{
+		[TearDown]
+		public void Cleanup()
+		{
+			DeviceServicesHost.ResetForTests();
+		}
+
+		[UnityTest]
+		public IEnumerator Instance_LazilySpawnsGameObject_DontDestroyOnLoad()
+		{
+			Assert.IsNull(GameObject.Find("DeviceServicesHost"), "Pre-condition: no host before access");
+
+			var host = DeviceServicesHost.Instance;
+			yield return null;
+
+			var go = GameObject.Find("DeviceServicesHost");
+			Assert.IsNotNull(go);
+			Assert.AreEqual(go, host.gameObject);
+			Assert.AreEqual("DontDestroyOnLoad", go.scene.name);
+		}
+
+		[UnityTest]
+		public IEnumerator RegisterLateUpdate_FiresEachLateUpdateFrame()
+		{
+			var host = DeviceServicesHost.Instance;
+			var callCount = 0;
+			host.RegisterLateUpdate(() => callCount++);
+
+			yield return null;
+			yield return null;
+
+			Assert.GreaterOrEqual(callCount, 2);
+		}
+
+		[UnityTest]
+		public IEnumerator RegisterSecondTick_FiresApproximatelyOncePerSecond()
+		{
+			var host = DeviceServicesHost.Instance;
+			var callCount = 0;
+			host.RegisterSecondTick(() => callCount++);
+
+			yield return new WaitForSecondsRealtime(2.2f);
+
+			Assert.GreaterOrEqual(callCount, 1);
+			Assert.LessOrEqual(callCount, 4, "Second-tick should not fire more than ~once per second");
+		}
+
+		[UnityTest]
+		public IEnumerator RegisterFocusChanged_FiresOnApplicationFocus()
+		{
+			var host = DeviceServicesHost.Instance;
+			yield return null;
+
+			var lastFocus = false;
+			var callCount = 0;
+			host.RegisterFocusChanged(focused =>
+			{
+				lastFocus = focused;
+				callCount++;
+			});
+
+			// Drive Unity's MonoBehaviour message dispatcher — same path the engine uses to
+			// notify focus changes. This is the documented black-box entry point for testing
+			// Unity callbacks; no private-field reflection is used.
+			host.SendMessage("OnApplicationFocus", true, SendMessageOptions.RequireReceiver);
+
+			Assert.AreEqual(1, callCount);
+			Assert.IsTrue(lastFocus);
+		}
+
+		[Test]
+		public void OnIosLowPowerModeChanged_PublicMethod_FanOutsToSubscribers()
+		{
+			var host = DeviceServicesHost.Instance;
+			var callCount = 0;
+			host.RegisterIosLowPowerModeChanged(() => callCount++);
+
+			// Public entry point — same one the iOS native bridge invokes via UnitySendMessage.
+			host.OnIosLowPowerModeChanged(string.Empty);
+			host.OnIosLowPowerModeChanged(string.Empty);
+
+			Assert.AreEqual(2, callCount);
+		}
+
+		[UnityTest]
+		public IEnumerator ResetForTests_DestroysSingleton()
+		{
+			_ = DeviceServicesHost.Instance;
+			Assert.IsNotNull(GameObject.Find("DeviceServicesHost"));
+
+			DeviceServicesHost.ResetForTests();
+
+			// In PlayMode ResetForTests routes through Object.Destroy(go) which is deferred
+			// to end-of-frame; yield once so Find can no longer locate the destroyed GO.
+			yield return null;
+
+			Assert.IsNull(GameObject.Find("DeviceServicesHost"));
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta
new file mode 100644
index 0000000..b2d1ace
--- /dev/null
+++ b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 24951a85b4e904685af703a3819e6322
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs
new file mode 100644
index 0000000..78f0b0f
--- /dev/null
+++ b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs
@@ -0,0 +1,134 @@
+using System.Collections;
+using GameLovers.MobileServices.Haptics;
+using GameLovers.MobileServices.Haptics.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class HapticsServicePlayModeTest
+	{
+		private FakeHapticsBackend _backend;
+		private HapticsService _haptics;
+
+		[SetUp]
+		public void Init()
+		{
+			_backend = new FakeHapticsBackend { IsSupportedValue = true };
+			_haptics = new HapticsService(_backend);
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_haptics.StopCurrentHaptic();
+
+			// HapticsHost is DontDestroyOnLoad and lazy-spawned; tear it down between tests so
+			// each [SetUp] starts from a clean slate without cross-test pollution.
+			var host = GameObject.Find("HapticsHost");
+			if (host != null)
+			{
+				Object.Destroy(host);
+			}
+		}
+
+		[UnityTest]
+		public IEnumerator PlayPresetDuration_Positive_AutoStopsAfterDuration()
+		{
+			_haptics.PlayPresetDuration(HapticPreset.Selection, 0.1f);
+			Assert.IsTrue(_haptics.IsPlaying);
+			Assert.AreEqual(0, _backend.StopCount);
+
+			yield return new WaitForSecondsRealtime(0.25f);
+
+			Assert.IsFalse(_haptics.IsPlaying);
+			Assert.AreEqual(1, _backend.StopCount);
+		}
+
+		[UnityTest]
+		public IEnumerator PlayCustom_AutoStopsAfterDurationMs()
+		{
+			_haptics.PlayCustom(0.7f, 100f);
+			Assert.IsTrue(_haptics.IsPlaying);
+			Assert.AreEqual(0, _backend.StopCount);
+
+			yield return new WaitForSecondsRealtime(0.25f);
+
+			Assert.IsFalse(_haptics.IsPlaying);
+			Assert.AreEqual(1, _backend.StopCount);
+		}
+
+		[UnityTest]
+		public IEnumerator StopCurrentHaptic_CancelsPendingAutoStop()
+		{
+			_haptics.PlayPresetDuration(HapticPreset.Warning, 0.2f);
+			_haptics.StopCurrentHaptic();
+			Assert.AreEqual(1, _backend.StopCount);
+			Assert.IsFalse(_haptics.IsPlaying);
+
+			yield return new WaitForSecondsRealtime(0.3f);
+
+			// No second Stop should have fired from a pending auto-stop coroutine.
+			Assert.AreEqual(1, _backend.StopCount);
+		}
+
+		[Test]
+		public void PlayCustom_ClampsIntensity01()
+		{
+			_haptics.PlayCustom(2.5f, 100f);
+			Assert.AreEqual(1f, _backend.LastIntensity);
+
+			_haptics.PlayCustom(-1f, 100f);
+			Assert.AreEqual(0f, _backend.LastIntensity);
+
+			_haptics.PlayCustom(0.42f, 100f);
+			Assert.AreEqual(0.42f, _backend.LastIntensity, 1e-6f);
+		}
+
+		[UnityTest]
+		public IEnumerator HapticsHost_OnDestroy_CancelsPendingAutoStop()
+		{
+			_haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, 0.5f);
+
+			var host = GameObject.Find("HapticsHost");
+			Assert.IsNotNull(host, "HapticsHost should be spawned by the time PlayPresetDuration with positive duration returns");
+
+			Object.Destroy(host);
+			yield return null;
+
+			yield return new WaitForSecondsRealtime(0.6f);
+
+			// Host destroyed before the scheduled stop fired: no Stop callback should have run.
+			// _isPlaying remains true on the service because the auto-stop coroutine never reached its callback;
+			// caller would normally observe this and explicitly call StopCurrentHaptic().
+			Assert.AreEqual(0, _backend.StopCount);
+		}
+
+		private sealed class FakeHapticsBackend : IHapticsBackend
+		{
+			public bool IsSupportedValue;
+			public int StopCount;
+			public float LastIntensity;
+			public float LastDurationMs;
+
+			public bool IsSupported => IsSupportedValue;
+
+			public void PlayPresetOneShot(HapticPreset preset) { }
+			public void PlayPresetLoop(HapticPreset preset) { }
+
+			public void PlayCustom(float intensity01, float durationMs)
+			{
+				LastIntensity = intensity01;
+				LastDurationMs = durationMs;
+			}
+
+			public void Stop()
+			{
+				StopCount++;
+			}
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta
new file mode 100644
index 0000000..f50a5ed
--- /dev/null
+++ b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a3742f45641024a648abc55678eca85c
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs
new file mode 100644
index 0000000..2e9e8db
--- /dev/null
+++ b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs
@@ -0,0 +1,133 @@
+using System.Collections;
+using GameLovers.MobileServices.Notifications;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class MobileNotificationServiceTest
+	{
+		private MobileNotificationService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			var defaultChannel = new GameNotificationChannel("default", "Default", "default channel");
+			_service = new MobileNotificationService(defaultChannel);
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			var go = GameObject.Find("NotificationService");
+			if (go != null)
+			{
+				Object.Destroy(go);
+			}
+		}
+
+		[UnityTest]
+		public IEnumerator Ctor_CreatesNotificationServiceGameObject_DontDestroyOnLoad()
+		{
+			yield return null;
+
+			var go = GameObject.Find("NotificationService");
+			Assert.IsNotNull(go, "MobileNotificationService should create a 'NotificationService' GameObject in its ctor");
+			Assert.AreEqual("DontDestroyOnLoad", go.scene.name,
+				"NotificationService GameObject should be marked DontDestroyOnLoad");
+		}
+
+		[Test]
+		public void CreateNotification_InEditor_ReturnsEditorGameNotification()
+		{
+			var notification = _service.CreateNotification();
+
+			Assert.IsInstanceOf<EditorGameNotification>(notification);
+		}
+
+		[Test]
+		public void ScheduleNotification_InEditor_AssignsGeneratedIdWhenNull_AndReturnsPending()
+		{
+			var notification = _service.CreateNotification();
+			Assert.IsFalse(notification.Id.HasValue);
+
+			var pending = _service.ScheduleNotification(notification);
+
+			Assert.IsNotNull(pending);
+			Assert.AreSame(notification, pending.Notification);
+			Assert.IsTrue(notification.Id.HasValue, "Editor scheduling should assign a generated id when none is provided");
+			Assert.IsTrue(notification.Id.Value >= 0, "Generated id should be non-negative (Math.Abs of GetHashCode)");
+		}
+
+		[Test]
+		public void ScheduleNotification_InEditor_PreservesProvidedId()
+		{
+			var notification = _service.CreateNotification();
+			notification.Id = 12345;
+
+			var pending = _service.ScheduleNotification(notification);
+
+			Assert.AreEqual(12345, pending.Notification.Id);
+		}
+
+		[Test]
+		public void CancelNotification_DismissNotification_DoNotThrow()
+		{
+			Assert.DoesNotThrow(() => _service.CancelNotification(1));
+			Assert.DoesNotThrow(() => _service.DismissNotification(1));
+		}
+
+		[Test]
+		public void CancelAllScheduledNotifications_DismissAllDisplayedNotifications_DoNotThrow()
+		{
+			Assert.DoesNotThrow(_service.CancelAllScheduledNotifications);
+			Assert.DoesNotThrow(_service.DismissAllDisplayedNotifications);
+		}
+
+		[Test]
+		public void Mode_SetValue_RoundTripsThroughHost()
+		{
+			Assert.AreEqual(OperatingMode.NoQueue, _service.Mode);
+
+			_service.Mode = OperatingMode.Queue;
+			Assert.AreEqual(OperatingMode.Queue, _service.Mode);
+
+			_service.Mode = OperatingMode.NoQueue;
+			Assert.AreEqual(OperatingMode.NoQueue, _service.Mode);
+		}
+
+		[Test]
+		public void PendingNotifications_FreshService_IsEmpty()
+		{
+			Assert.IsNotNull(_service.PendingNotifications);
+			Assert.AreEqual(0, _service.PendingNotifications.Count);
+		}
+
+		[Test]
+		public void OnLocalNotificationDeliveredEvent_EditorSchedule_DoesNotFire()
+		{
+			var fireCount = 0;
+			_service.OnLocalNotificationDeliveredEvent += _ => fireCount++;
+
+			_service.ScheduleNotification(_service.CreateNotification());
+
+			Assert.AreEqual(0, fireCount,
+				"Delivery is platform-driven (manual-only per Tests/AGENTS.md §9); scheduling in the Editor must not synchronously fire the delivered event.");
+		}
+
+		[Test]
+		public void OnLocalNotificationExpiredEvent_EditorSchedule_DoesNotFire()
+		{
+			var fireCount = 0;
+			_service.OnLocalNotificationExpiredEvent += _ => fireCount++;
+
+			_service.ScheduleNotification(_service.CreateNotification());
+
+			Assert.AreEqual(0, fireCount,
+				"Foreground-expiry is platform-driven (manual-only per Tests/AGENTS.md §9); scheduling in the Editor must not synchronously fire the expired event.");
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta
new file mode 100644
index 0000000..3ea3ccb
--- /dev/null
+++ b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 284d6cb1d57bd4c928a3a494b9362592
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs b/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs
new file mode 100644
index 0000000..77e737b
--- /dev/null
+++ b/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs
@@ -0,0 +1,43 @@
+using GameLovers.MobileServices;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class MobileServicePlayModeTest
+	{
+		private MobileService _mobile;
+
+		[SetUp]
+		public void Init()
+		{
+			_mobile = new MobileService();
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_mobile.Dispose();
+
+			var go = GameObject.Find("NotificationService");
+			if (go != null)
+			{
+				Object.Destroy(go);
+			}
+
+			DeviceServicesHost.ResetForTests();
+		}
+
+		[Test]
+		public void DefaultCtor_WiresAllChildren_NonNull()
+		{
+			Assert.IsNotNull(_mobile.NativeUi);
+			Assert.IsNotNull(_mobile.Notifications);
+			Assert.IsNotNull(_mobile.Haptics);
+			Assert.IsNotNull(_mobile.Device);
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs.meta b/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs.meta
new file mode 100644
index 0000000..c6180a7
--- /dev/null
+++ b/Tests/PlayMode/Unit/MobileServicePlayModeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75bef5de574b549628e7d5fb1f14417d
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs
new file mode 100644
index 0000000..212517a
--- /dev/null
+++ b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs
@@ -0,0 +1,67 @@
+using System.Collections;
+using System.Threading.Tasks;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class PermissionsCallbackReceiverTest
+	{
+		[TearDown]
+		public void Cleanup()
+		{
+			var go = GameObject.Find("PermissionsCallbackReceiver");
+			if (go != null)
+			{
+				Object.Destroy(go);
+			}
+		}
+
+		[UnityTest]
+		public IEnumerator OnPermissionResult_ValidPayload_ResolvesPendingTcs()
+		{
+			var receiver = PermissionsCallbackReceiver.Instance;
+			var tcs = new TaskCompletionSource<PermissionStatus>();
+			var id = receiver.Register(tcs);
+
+			receiver.OnPermissionResult($"{id}:{(int) PermissionStatus.Granted}");
+
+			yield return null;
+
+			Assert.IsTrue(tcs.Task.IsCompleted);
+			Assert.AreEqual(PermissionStatus.Granted, tcs.Task.Result);
+		}
+
+		[UnityTest]
+		public IEnumerator OnPermissionResult_MalformedPayload_DoesNotThrow()
+		{
+			var receiver = PermissionsCallbackReceiver.Instance;
+			var tcs = new TaskCompletionSource<PermissionStatus>();
+			var id = receiver.Register(tcs);
+
+			Assert.DoesNotThrow(() => receiver.OnPermissionResult("not-a-valid-payload"));
+			Assert.DoesNotThrow(() => receiver.OnPermissionResult($"{id}:not-an-int"));
+			Assert.DoesNotThrow(() => receiver.OnPermissionResult(":1"));
+
+			yield return null;
+
+			Assert.IsFalse(tcs.Task.IsCompleted, "Malformed payloads should not resolve the registered TCS.");
+		}
+
+		[UnityTest]
+		public IEnumerator OnPermissionResult_UnknownId_NoOp()
+		{
+			var receiver = PermissionsCallbackReceiver.Instance;
+
+			receiver.OnPermissionResult($"99999:{(int) PermissionStatus.Denied}");
+			yield return null;
+
+			Assert.Pass();
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta
new file mode 100644
index 0000000..d3b8202
--- /dev/null
+++ b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e3e3402527c084641b4d257cedf38bbf
\ No newline at end of file
diff --git a/Tests/PlayMode/Unit/SafeAreaServiceTest.cs b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs
new file mode 100644
index 0000000..2aa998d
--- /dev/null
+++ b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs
@@ -0,0 +1,72 @@
+using System.Collections;
+using GameLovers.MobileServices.Device;
+using GameLovers.MobileServices.Device.Internal;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+// ReSharper disable once CheckNamespace
+
+namespace GameLoversEditor.MobileServices.Tests
+{
+	public class SafeAreaServiceTest
+	{
+		private SafeAreaService _service;
+
+		[SetUp]
+		public void Init()
+		{
+			_service = new SafeAreaService(DeviceServicesHost.Instance);
+		}
+
+		[TearDown]
+		public void Cleanup()
+		{
+			_service.Dispose();
+			DeviceServicesHost.ResetForTests();
+		}
+
+		[Test]
+		public void Ctor_CapturesInitialSafeArea()
+		{
+			Assert.AreEqual(Screen.safeArea, _service.SafeArea);
+		}
+
+		[Test]
+		public void DefaultCtor_UsesSharedHost_CapturesInitialSafeArea()
+		{
+			var service = new SafeAreaService();
+
+			Assert.AreEqual(Screen.safeArea, service.SafeArea);
+
+			service.Dispose();
+		}
+
+		[UnityTest]
+		public IEnumerator Tick_ScreenSafeAreaUnchanged_DoesNotFireEvent()
+		{
+			var fireCount = 0;
+			_service.OnSafeAreaChanged += _ => fireCount++;
+
+			yield return null;
+			yield return null;
+			yield return null;
+
+			Assert.AreEqual(0, fireCount);
+		}
+
+		[UnityTest]
+		public IEnumerator Dispose_UnregistersFromHost()
+		{
+			var fireCountAfterDispose = 0;
+			_service.OnSafeAreaChanged += _ => fireCountAfterDispose++;
+			_service.Dispose();
+
+			yield return null;
+			yield return null;
+
+			Assert.AreEqual(0, fireCountAfterDispose,
+				"After Dispose, the safe-area service should be detached from the host's LateUpdate fan-out.");
+		}
+	}
+}
diff --git a/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta
new file mode 100644
index 0000000..e0c418e
--- /dev/null
+++ b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dfe42e63e619e49be8f2302e6f8f7e0d
\ No newline at end of file
diff --git a/Third Party Notices.md b/Third Party Notices.md
deleted file mode 100644
index 32bb0a8..0000000
--- a/Third Party Notices.md	
+++ /dev/null
@@ -1,16 +0,0 @@
-This package contains third-party software components governed by the license(s) indicated below:
----------
-
-Component Name: [provide component name]
-
-License Type: [Provide license type, i.e. "MIT", "Apache 2.0"]
-
-[Provide License Details]
-
----------
-Component Name: [provide component name]
-
-License Type: [Provide license type, i.e. "MIT", "Apache 2.0"]
-
-[Provide License Details]
-
diff --git a/docs.meta b/docs.meta
new file mode 100644
index 0000000..84f0632
--- /dev/null
+++ b/docs.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4b5fbdc02f8ae4a87891ce44ae79dda0
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..fd4e559
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,27 @@
+# GameLovers Mobile Services — API Reference
+
+This folder contains the full API reference for each subsystem, plus the editor-side tooling docs (Device Simulator panel, Build Pipeline) that the package's `README.md` only links to. For quick-start examples see the [package README](../README.md); for architecture details and contributor guidance see [AGENTS.md](../AGENTS.md).
+
+## Subsystems
+
+| Subsystem | File |
+|-----------|------|
+| Native UI | [native-ui.md](native-ui.md) |
+| Notifications | [notifications.md](notifications.md) |
+| Haptics | [haptics.md](haptics.md) |
+| Gestures | [gestures.md](gestures.md) |
+| Device (umbrella + 8 children) | [device.md](device.md) |
+
+## Editor tooling
+
+| Topic | File |
+|-------|------|
+| Device Simulator panel & in-Game-view simulator overlay | [explorer.md](explorer.md) |
+| Build pipeline & Project Settings | [build-pipeline.md](build-pipeline.md) |
+
+## Other
+
+| Topic | File |
+|-------|------|
+| Samples index | [samples.md](samples.md) |
+| Troubleshooting & symptoms→fix | [troubleshooting.md](troubleshooting.md) |
diff --git a/docs/README.md.meta b/docs/README.md.meta
new file mode 100644
index 0000000..52af993
--- /dev/null
+++ b/docs/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 4dffc1e6c5bcd4c1591fbc788cd59613
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/build-pipeline.md b/docs/build-pipeline.md
new file mode 100644
index 0000000..da70875
--- /dev/null
+++ b/docs/build-pipeline.md
@@ -0,0 +1,134 @@
+# Build Pipeline & Config
+
+The package ships a build postprocessor (`MobileServicesBuildPostprocessor`) and a `MobileServicesConfig` asset that together automate the iOS Info.plist / .entitlements / Android manifest / gradle mutations the framework's permission and capability surface needs.
+
+## Mobile Services Config asset
+
+`MobileServicesConfig` is an **editor-only `ScriptableObject` asset** (not a Project Settings page). Open it via **`Tools > GameLovers > Mobile Services > Select Mobile Services Config`** — this finds the single config anywhere in the project (`AssetDatabase.FindAssets`) or creates one under `Assets/Editor/` on first use, then selects it so you edit it in the Inspector. Keep it under an `Editor/` folder (the type lives in the Editor assembly, so the asset is editor-only and never ships in a player build). The asset is intended to be committed to VCS — it's team-shared state.
+
+The Inspector (`MobileServicesConfigEditor`) shows:
+
+- **Status box** — info when every referenced permission has an English usage description; a warning listing the count otherwise.
+- **Usage descriptions (localized)** — `Permission Descriptions` is a list with one entry per `AppPermission`, and each holds a **per-locale list** of `(LocaleCode, UsageDescription)`. The `en` value is the base; add `fr`, `pt-BR`, … to localize. (Notifications has no iOS Info.plist key, so it's not emitted.)
+- **App Tracking (ATT) Description** — the per-locale `NSUserTrackingUsageDescription` list. Only marked missing when the App Tracking capability is enabled.
+- **Capabilities** — `Push Notifications`, `App Tracking`, `Associated Domains` (+ domain list). (Background audio is not here — use Unity's own **Player Settings > iOS > Behavior in Background = Custom > Audio**.)
+- **Android manifest** — `CAMERA`, `RECORD_AUDIO`, `ACCESS_FINE_LOCATION`, `READ_MEDIA_IMAGES`, `POST_NOTIFICATIONS`, share-chooser `<queries>` block.
+- **Android dependencies** — `Include Play Review Dependency` toggle (**ON by default**) + editable `Play Review Coordinate` (default `com.google.android.play:review:2.0.2`). When on, the Play In-App Review library is auto-injected into the generated Gradle project so `NativeUiService.RequestReview()` works with zero manual setup.
+- **Build behaviour** — `Manage Native Build Manually` (**OFF by default**) — when ON, the package writes nothing to the iOS/Android build and skips the iOS usage-description validation. It's the single escape for teams that configure the native build (Xcode / Gradle) themselves. (This is *build* configuration — unrelated to render post-processing.)
+- **Tools** — `Scan project for used services` (reflection-based pre-fill of capability toggles), `Fill missing English descriptions with suggested copy`, `Generate iOS Privacy Nutrition Label draft`.
+
+Accessed in code via `MobileServicesConfig.Instance` (cached locator; returns a transient default if no asset exists, so build/tests never fail).
+
+## Build postprocessor
+
+`MobileServicesBuildPostprocessor : IPostprocessBuildWithReport` runs on every iOS / Android build.
+
+### Validation step (fail-fast)
+
+Reads the config asset + runs the project scanner. For each referenced permission that has an Info.plist key but an empty usage description, the postprocessor throws `BuildFailedException` listing every missing key, with a fix hint pointing at the config asset (and the `Fill missing English descriptions with suggested copy` button). The same applies to `NSUserTrackingUsageDescription` when App Tracking is enabled. To bypass validation entirely (you manage `Info.plist` yourself), enable `Manage Native Build Manually`.
+
+### iOS injection step
+
+After validation, the postprocessor mutates the post-build Xcode project:
+
+- **Info.plist** — writes every configured **base (`en`)** usage description string. When any non-`en` locale is configured, also writes `CFBundleLocalizations` (+ `CFBundleDevelopmentRegion`).
+- **Localized usage descriptions** — for every non-`en` `LocaleEntry`, writes a `<locale>.lproj/InfoPlist.strings` file (the platform-native format: `"NSCameraUsageDescription" = "…";`) and registers it on the main target so Xcode copies it into the app bundle. iOS then shows the description in the device language, falling back to the `Info.plist` base value. (The in-memory `PBXProject` is saved before `ProjectCapabilityManager` runs so the registration isn't clobbered.)
+- **Entitlements** — opens / creates `GameLoversMobileServices.entitlements` via `ProjectCapabilityManager` and adds Push Notifications / Associated Domains capabilities per the settings.
+
+Idempotent — re-running against the same Xcode project produces no diff if the configured state already matches.
+
+### Android injection step
+
+Patches `Assets/Plugins/Android/mainTemplate.xml`:
+
+- Appends `<uses-permission android:name="..." />` entries for the configured toggles (Camera, Mic, Location, Photo Library, Notifications). Idempotent (skips entries already present).
+- Appends a `<queries><intent><action android:name="android.intent.action.SEND" /><data android:mimeType="*/*" /></intent></queries>` block when the share-chooser opt-in is on (Android 11+ visibility requirement for share targets).
+
+If `mainTemplate.xml` is absent, the postprocessor logs a warning pointing at `Player Settings > Publishing Settings > Custom Main Manifest` — Unity won't generate one automatically.
+
+### Android Gradle dependency step (Play In-App Review)
+
+`MobileServicesBuildPostprocessor` also implements `IPostGenerateGradleAndroidProject`. When `Include Play Review dependency` is on (the default), it injects `implementation '<coordinate>'` (default `com.google.android.play:review:2.0.2`) into the generated module `build.gradle` so `NativeUiService.RequestReview()` works on Android with zero manual setup.
+
+- **Conflict-safe**: it scans every `.gradle` file in the generated project and skips entirely if `com.google.android.play:review` is already declared by any source (hand-written gradle, EDM4U, another SDK) — it never double-declares or fights your version pin.
+- **Editable**: repoint `PlayReviewDependencyCoordinate` to an internal mirror or a pinned/forced version to resolve a Gradle conflict.
+- **Opt-out**: turn `Include Play Review dependency` off for non-Play targets (Amazon / Huawei / sideload) and declare it yourself.
+
+## Build mutation map & escape hatches
+
+Everything the package writes at build time, and how to override or disable each piece. The contract is **additive, idempotent, individually opt-out-able, and always yields to consumer-owned config** — you can resolve any third-party-SDK conflict without forking the package.
+
+| Platform | What is written | File | Override / opt-out |
+|----------|-----------------|------|--------------------|
+| iOS | Base (`en`) usage description strings | `Info.plist` | Per-permission text in the config asset; set the key yourself and it is not overwritten |
+| iOS | Localized usage descriptions + `CFBundleLocalizations` | `<locale>.lproj/InfoPlist.strings` | Add/remove `LocaleEntry` rows per permission/ATT; emitted only for non-`en` locales |
+| iOS | Push / Associated Domains capabilities | `GameLoversMobileServices.entitlements` | Per-capability toggles (see entitlements caveat below) |
+| Android | `<uses-permission>` entries | `mainTemplate.xml` | Per-permission Android-manifest toggles; skipped if already present |
+| Android | Share `<queries>` block | `mainTemplate.xml` | `IncludeShareQueriesBlock` toggle; skipped if `ACTION_SEND` already present |
+| Android | `com.google.android.play:review` dependency | generated `build.gradle` | `IncludePlayReviewDependency` toggle + editable `PlayReviewDependencyCoordinate`; skipped if already declared anywhere |
+
+Cross-cutting controls:
+
+- **`Manage Native Build Manually`** (`ManageNativeBuildManually`, default OFF) — global escape. Makes the package perform zero native-build configuration (and skips the fail-fast iOS usage-description validation). For teams that fully configure the native build themselves or via another build tool.
+- **iOS entitlements caveat** — capabilities are written to a dedicated `GameLoversMobileServices.entitlements`. Xcode allows only one entitlements file per target, so if another SDK ships its own, disable the package's capability toggles and fold the keys into the other SDK's file (or use the kill-switch and manage entitlements yourself). Plist keys are set, not blind-overwritten when a value already exists.
+
+## Project scanner
+
+`MobileServicesScanner.Scan()` reflects over the project's user assemblies looking for type references to:
+
+- `MobileNotificationService` → `UsesNotifications` (drives Push Notifications capability)
+- `DeepLinkService` → `UsesDeepLinks` (drives Associated Domains capability)
+- `AttService` / `IAttService` → `UsesAtt` (drives App Tracking capability)
+- `PermissionsService` / `IPermissionsService` → flags every `AppPermission` as potentially required
+- `NativeUiService` → `UsesNativeUiShare` (drives Android share-chooser queries block)
+
+The scan is intentionally pessimistic for permissions — when permissions are referenced but the scanner can't statically infer WHICH permissions, all of them flag. The user un-toggles the ones they don't actually call. False positives (capability enabled when not strictly needed) are preferable to false negatives (build ships missing an entitlement).
+
+## Manual fallback
+
+Teams that prefer to manage `Info.plist` / `.entitlements` / `mainTemplate.xml` by hand can opt out of the automation by leaving every capability toggle off in the config asset (or enabling `Manage Native Build Manually`). The postprocessor's validation step still runs but with nothing to inject. In that case, the configuration steps you need to follow:
+
+### iOS Info.plist
+
+| Permission | Key |
+|------------|-----|
+| Camera | `NSCameraUsageDescription` |
+| Microphone | `NSMicrophoneUsageDescription` |
+| Location (when in use) | `NSLocationWhenInUseUsageDescription` |
+| Location (always) | `NSLocationAlwaysAndWhenInUseUsageDescription` |
+| Photo Library | `NSPhotoLibraryUsageDescription` |
+| Photo Library (add only) | `NSPhotoLibraryAddUsageDescription` |
+| App Tracking | `NSUserTrackingUsageDescription` |
+
+### iOS .entitlements
+
+| Capability | Entitlement |
+|------------|-------------|
+| Push Notifications | `aps-environment` (set to `development` or `production`) |
+| Associated Domains | `com.apple.developer.associated-domains` array |
+
+### Android manifest
+
+```xml
+<uses-permission android:name="android.permission.CAMERA" />
+<uses-permission android:name="android.permission.RECORD_AUDIO" />
+<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+<!-- For share-sheet visibility on API 30+ -->
+<queries>
+    <intent>
+        <action android:name="android.intent.action.SEND" />
+        <data android:mimeType="*/*" />
+    </intent>
+</queries>
+```
+
+### Android gradle (for `NativeUiService.RequestReview()`)
+
+Auto-injected by default (see the Android Gradle dependency step above). Only needed manually if you turn `Include Play Review dependency` off:
+
+```gradle
+implementation 'com.google.android.play:review:2.0.2'
+```
diff --git a/docs/build-pipeline.md.meta b/docs/build-pipeline.md.meta
new file mode 100644
index 0000000..6b941e9
--- /dev/null
+++ b/docs/build-pipeline.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: c65e72051a52646c0acec38a7c625793
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/device.md b/docs/device.md
new file mode 100644
index 0000000..ac8c6ab
--- /dev/null
+++ b/docs/device.md
@@ -0,0 +1,124 @@
+# Device
+
+`IDeviceService` is an umbrella facade aggregating seven independently mockable sub-services. Each child interface is also independently registerable for testing.
+
+```csharp
+IDeviceService device = new DeviceService();
+
+device.Battery.OnLowPowerModeChanged += () =>
+    Debug.Log($"LPM -> {device.Battery.IsLowPowerMode}");
+
+device.ScreenWake.KeepAwake = true;
+device.AudioSession.ConfigureForPlayback();
+
+var camera = await device.Permissions.RequestAsync(AppPermission.Camera);
+var att    = await device.Att.RequestAuthorizationAsync();
+
+device.DeepLink.OnLinkActivated += uri => Debug.Log($"Deep link: {uri}");
+```
+
+## Children
+
+| Property | Interface | What it does |
+|----------|-----------|--------------|
+| `SafeArea` | `ISafeAreaService` | `Screen.safeArea` with change events. Pairs with `SafeAreaContainer` UI Toolkit element. |
+| `ScreenWake` | `IScreenWakeService` | `Screen.sleepTimeout` toggle. Idempotent. |
+| `Battery` | `IBatteryService` | Level + status + low-power-mode events (iOS `NSProcessInfoPowerStateDidChangeNotification`, Android `PowerManager.isPowerSaveMode`). |
+| `AudioSession` | `IIosAudioSessionService` | iOS `AVAudioSession` category override (silent-switch). No-op on Android / Editor. |
+| `Permissions` | `IPermissionsService` | Unified iOS+Android runtime permissions. Task-based async. Multi-permission overload. |
+| `Att` | `IAttService` | iOS App Tracking Transparency. Direct `ATTrackingManager` bridge — no `com.unity.ads.ios-support` dependency. |
+| `DeepLink` | `IDeepLinkService` | `Application.deepLinkActivated` wrapper with cold-start link queueing. |
+
+Additionally, an `IDeepLinkRouter` / `DeepLinkRouter` layered on `IDeepLinkService` provides path-pattern routing — see below.
+
+## Shared host
+
+Every event-driven child shares a single internal `MonoBehaviour` (`DeviceServicesHost`, `DontDestroyOnLoad`, lazily spawned). One `RegisterLateUpdate` / `RegisterSecondTick` / `RegisterFocusChanged` / `RegisterIosLowPowerModeChanged` fan-out drives the entire device subsystem. **The runtime cost of the entire Device subsystem is one auto-spawned GameObject** — not one per service.
+
+`DeviceServicesHost.ResetForTests()` is exposed for EditMode test teardown.
+
+## Permissions
+
+```csharp
+public enum AppPermission { Camera, Microphone, LocationWhenInUse, LocationAlways, PhotoLibrary, PhotoLibraryAddOnly, Notifications }
+public enum PermissionStatus { NotDetermined, Denied, Granted, Restricted }
+
+PermissionStatus Check(AppPermission permission);              // sync, no prompt
+Task<PermissionStatus> RequestAsync(AppPermission permission); // async, may prompt
+
+// Multi-permission convenience — awaits sequentially (iOS prompts cannot stack).
+Task<IReadOnlyDictionary<AppPermission, PermissionStatus>> RequestAsync(params AppPermission[] permissions);
+```
+
+iOS callbacks land at `PermissionsCallbackReceiver` (auto-spawned `DontDestroyOnLoad` GameObject) via `UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", "<id>:<status>")`. Android uses Unity's `UnityEngine.Android.Permission` API.
+
+Android API 33+ requirements (`READ_MEDIA_IMAGES` for Photos, `POST_NOTIFICATIONS` for Notifications) are baked into the `AndroidManifestPermission` mapping. Manifest entries are auto-injected by the build postprocessor when the matching capability toggle is on — see [build-pipeline.md](build-pipeline.md).
+
+## App Tracking Transparency (`IAttService`)
+
+```csharp
+public enum AttStatus { NotDetermined, Restricted, Denied, Authorized }
+
+AttStatus CurrentStatus { get; }
+Task<AttStatus> RequestAuthorizationAsync();
+```
+
+- **iOS 14.5+** — bridges `ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler:` via `Plugins/iOS/Att.m`.
+- **iOS < 14.5 / Android / Editor / unsupported** — returns `Authorized`. **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.
+
+## Deep Links
+
+```csharp
+public interface IDeepLinkService
+{
+    event Action<Uri> OnLinkActivated;        // runtime + cold-start replay
+    Uri PendingColdStartLink { get; }         // null after first consume / never set
+}
+```
+
+The cold-start link (captured from `Application.absoluteURL` at construction) is replayed to the **first** subscriber only — subsequent subscribers do NOT receive it. Construct the service early in app bootstrap (before scene load).
+
+### Deep Link Router
+
+For pattern-based routing rather than a giant switch in your `OnLinkActivated` handler:
+
+```csharp
+public interface IDeepLinkRouter
+{
+    void MapRoute(string pathPattern, Action<Uri, IReadOnlyDictionary<string, string>> handler);
+    void RemoveRoute(string pathPattern);
+    bool TryDispatch(Uri uri);
+}
+```
+
+```csharp
+var router = new DeepLinkRouter(device.DeepLink);
+router.MapRoute("/promo/:id", (uri, p) => OpenPromo(p["id"]));
+router.MapRoute("/profile/:userId", (uri, p) => OpenProfile(p["userId"]));
+router.MapRoute("/settings", (uri, p) => OpenSettings());
+```
+
+Literal segments match exactly (case-insensitive); `:name` segments capture into the params dict. Routes are checked in registration order — first match wins. The router subscribes once to `OnLinkActivated` at construction.
+
+## Safe Area
+
+`SafeAreaContainer` is a companion UI Toolkit `VisualElement` that pads itself to the safe area:
+
+```csharp
+var container = new SafeAreaContainer(device.SafeArea);
+rootVisualElement.Add(container);
+```
+
+For UXML usage, construct via the default constructor and call `SetSafeAreaService` once the service is available:
+
+```xml
+<ui:UXML xmlns:gl="GameLovers.MobileServices.Device">
+    <gl:SafeAreaContainer>
+        <!-- content -->
+    </gl:SafeAreaContainer>
+</ui:UXML>
+```
+
+```csharp
+rootVisualElement.Q<SafeAreaContainer>().SetSafeAreaService(device.SafeArea);
+```
diff --git a/docs/device.md.meta b/docs/device.md.meta
new file mode 100644
index 0000000..05957da
--- /dev/null
+++ b/docs/device.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9e72eaaea610e45ef879b18d09fb80d5
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/explorer.md b/docs/explorer.md
new file mode 100644
index 0000000..63355eb
--- /dev/null
+++ b/docs/explorer.md
@@ -0,0 +1,136 @@
+# Mobile Services Device Simulator Panel & Overlay
+
+All Mobile Services editor tooling lives inside **Unity's Device Simulator** (`Window > General > Device Simulator`). There is no separate Explorer or Simulator window — a single **Mobile Services** panel provides the controls, the live diagnostics, and the haptic envelope graph, and an in-Game-view overlay paints the platform-shaped mocks **inside the simulated phone screen** (edit and play mode).
+
+## The Mobile Services panel
+
+Open `Window > General > Device Simulator`, pick a device profile, and the **Mobile Services** panel appears automatically in the Control Panel (`UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` — Unity auto-discovers it; no menu item, no enable flow). The platform skin auto-syncs from the selected device profile (it reads `Application.platform`, which the Device Simulator spoofs for iOS / Android picks), so there is no platform toggle to keep in sync.
+
+| Foldout | What it surfaces / drives |
+|---------|---------------------------|
+| **Native UI** | Alert (modal + sheet), toast (short/long), share. Title/message/text fields author the mock; buttons push the platform-shaped mock to the overlay AND call the real `NativeUiService` static methods (no-op in editor). The Action Sheet button greys out on Android (no native sheet idiom). |
+| **Haptics** | 9 preset buttons that plot the preset's **intensity-over-time curve** — a `Painter2D` line/area graph with **X = time (ms)** and **Y = intensity (0–1)**, drawn as a step waveform from the canonical `HapticEnvelopes` tables (the same data the Android backend feeds to `VibrationEffect.createWaveform`), with axis ticks. Haptics fire only on a physical device, so there are deliberately no play / stop controls here; the graph is the editor calibration cue. |
+| **Notifications** | A single **Show heads-up banner** preview (edit mode) that pushes the mock to the overlay, like the Native UI mocks. Live scheduling / channels / queueing / pending were removed — they ran on a throwaway `MobileNotificationService` disconnected from your game and duplicated the `NotificationsScheduler` sample (which drives the game's own service). |
+| **Gestures** | Last-detected swipe (direction / velocity / sameness / start+end) and last-detected tap (position / duration). In Play mode it uses a scene `GestureController` if one exists, otherwise **auto-spawns a hidden one** (and enables Input System Touch Simulation) so it works with zero setup — swipe / tap anywhere. |
+| **Permissions** | A per-`AppPermission` **state dropdown** that is the **Settings surface** — pick `Granted` / `Denied` / `NotDetermined` / `Restricted`, mirroring the user toggling the permission in the OS Settings app. It models the **real OS lifecycle**: when the running game calls `RequestAsync()` on a `NotDetermined` permission the first time, the OS prompt renders in the overlay and resolves when the user answers; the decision persists (`EditorPrefs`) and repeat requests return the cached decision with no prompt. Set the dropdown back to `NotDetermined` (or hit `Reset all to NotDetermined`) to re-arm the prompt. A play-mode-gated **Allow / Don't Allow** fallback appears while a prompt is pending (overlay clicks are unreliable in the edit-mode Game view). |
+| **App Tracking Transparency** | A single **status dropdown** (the Settings surface) that models the same lifecycle: the first runtime `RequestAuthorizationAsync()` on a `NotDetermined` status shows the ATT prompt in the overlay (**iOS skin only** — Android / other skins return `Authorized`), then caches the decision. Plus a `Reset to NotDetermined` button and a pending-prompt **Allow / Ask Not to Track** fallback. |
+
+App review is intentionally **not** a panel foldout: it is stateless and fire-and-forget, so there is nothing to configure and no manual trigger to expose (an info-only foldout would not earn its place). Like Permissions / ATT, the review prompt is **shown only when your game code calls** `NativeUiService.RequestReview()` (play mode). While the simulator is engaged, the editor-only `EditorRequestReviewOverride` hook then renders the platform-shaped mock in the overlay — the iOS StoreKit centered star sheet (titled with `Application.productName`) or the Android Play bottom sheet — and the mock's own buttons close it, just like the real prompt. A log is written when the prompt is shown (mirroring the device, which gives no "was shown" callback).
+
+Deep links are intentionally **not** a panel foldout: `DeepLinkService.SimulateLinkActivated` is instance-scoped (no static override like Permissions/ATT), so the panel could only ever fire into a throwaway instance it owns — never your game's live service. Drive deep links from the `DeepLinkRouter` sample, or call `EditorPlatformSimulator.SimulateDeepLink(uri, yourService)` from your own bootstrap.
+
+The header carries an **Editor Simulator** master-switch toggle: it enables/disables every section below as a group and shows/hides the in-Game-view `[EDITOR SIMULATOR]` banner (state persisted to `EditorPrefs`); turning it off also clears any visible mock. While the simulator is on the panel **engages** the OS-faithful Permission / ATT overrides (`EditorPlatformSimulator.Engage()`); turning it off (or closing the panel) **disengages** them so a non-engaged editor session keeps the default `Granted` / `Authorized` short-circuit. Dismissal lives per-section — **Dismiss all UIs** in the Native UI foldout and **Dismiss Banner** in the Notifications foldout.
+
+**Play-mode gating**: the **Permission / ATT state dropdowns** and their reset buttons are the Settings surface and now write `EditorPrefs`, so they are meaningful in **edit mode** too (the decision survives the Play domain reload) — they are no longer greyed out. The controls that still need **Play mode** are the ones that act on a running consumer: the pending-prompt **Allow / Don't Allow** fallback buttons (a prompt only pends when the game actually calls `RequestAsync()` at runtime) and the Gestures read-out (which auto-attaches a `GestureController` in Play mode). An amber banner at the top of the panel explains the remaining gating and auto-hides in Play mode. Edit-mode-safe controls — every native-UI mock push, the haptic preset buttons + envelope graph, and the state dropdowns — stay enabled.
+
+## The simulator overlay (the canvas)
+
+`MobileSimulatorRuntimeOverlay` is an editor-only `[InitializeOnLoad]` bootstrap that spawns a `[EditorOnly] MobileSimulatorOverlay` GameObject carrying a `UIDocument` with `PanelSettings.sortingOrder = short.MaxValue`. It renders **inside the Game / Simulator view at the simulated device's pixel grid**, so an "iOS top-banner toast" sits at the top of the simulated iPhone screen — not at the top of the editor's Game window.
+
+- **Alive while the panel is open** — the plugin calls `MobileSimulatorRuntimeOverlay.NotifyPluginActive(true/false)` on create / destroy, so the overlay exists exactly while the Device Simulator panel is open, in **edit mode and play mode**. `UIDocument` is `[ExecuteAlways]`, so it paints in the edit-mode Game view too. Fire a mock from the panel without entering play mode and it renders immediately.
+- **Display-only in edit mode** — runtime-panel pointer input is unreliable in the edit-mode Game view, so the mock's own buttons are not relied upon; dismissal is driven from the panel's per-section dismiss buttons (**Dismiss all UIs** / **Dismiss Banner**).
+- **Composes with Unity's Device Simulator** — pick "iPhone 15 Pro" in the Device Simulator, and the mocks render at the right scale and safe-area inset for that device.
+
+The same mock payloads — alerts, action sheets, toasts, share sheets, review prompts, heads-up banners, permission / ATT dialogs — are built by `MockBuilders` and skinned by the three USS files (`MobileSimulator.Common.uss` / `.iOS.uss` / `.Android.uss`); the root element toggles `platform-ios` / `platform-android` so USS rules can scope on either.
+
+## Editor Simulator watermark
+
+The overlay paints an `[EDITOR SIMULATOR]` watermark in the Game view while the simulator is enabled (the header **Editor Simulator** toggle / `MobileSimulatorState.Enabled`). It exists to prevent the "looked fine in editor, broke on device" trust collapse — it is tied to the master switch, so it disappears only when the whole simulator is turned off (which also disables every panel control).
+
+## `EditorPlatformSimulator` static API
+
+For code-driven tests / scripted automation, `GameLovers.MobileServices.Editor.Simulation.EditorPlatformSimulator` exposes:
+
+```csharp
+EditorPlatformSimulator.Engage();   // install the OS-faithful Permission / ATT overrides
+EditorPlatformSimulator.SetIosLowPowerMode(true, batteryService);
+EditorPlatformSimulator.SetSafeArea(new Rect(0, 100, w, h-200), safeAreaService);
+EditorPlatformSimulator.ClearSafeAreaOverride(safeAreaService);
+EditorPlatformSimulator.SimulateDeepLink(new Uri("myapp://promo/x"), deepLinkService);
+
+// Permissions / ATT model the real OS lifecycle: set the persisted decision (the "Settings"
+// surface), or leave it NotDetermined so the first RequestAsync() shows a prompt in the overlay.
+EditorPlatformSimulator.SetPermissionState(AppPermission.Camera, PermissionStatus.Denied);
+EditorPlatformSimulator.GetPermissionState(AppPermission.Camera);   // read it back
+EditorPlatformSimulator.ResetAllPermissions();                      // re-arm every prompt
+EditorPlatformSimulator.ResolvePendingPermissionPrompt(AppPermission.Camera, allow: true);
+EditorPlatformSimulator.SetAttState(AttStatus.Authorized);
+EditorPlatformSimulator.ResetAtt();
+EditorPlatformSimulator.ResolvePendingAttPrompt(allow: false);
+
+EditorPlatformSimulator.DismissAllOverlays();
+EditorPlatformSimulator.Disengage(); // restore the default Granted / Authorized short-circuit
+```
+
+`Engage()` installs the editor overrides so `Check()` / `CurrentStatus` read the persisted simulated decision and a `RequestAsync()` on a `NotDetermined` entry pushes a prompt through the `MobileSimulatorState` broker; `Disengage()` removes them. The `Set*` / `Reset*` helpers write the `EditorPrefs`-backed store; `ResolvePending*` answers a prompt that is awaiting a decision. Other methods set an internal static override on the runtime service (under `#if UNITY_EDITOR`) or push a payload through the broker.
+
+## What's NOT mirrored
+
+Out of scope (deliberate):
+
+- **Audio proxy for haptics** (low-frequency oscillator burst per preset).
+
+The envelope graph is the calibration cue for haptics; designers iterate haptic feel on a paired device through the `HapticsPalette` sample. Device-frame overlays (iPhone 15 Pro / Pixel 8 cutout outlines, safe-area inset, `Application.platform` spoofing) are handled by Unity's Device Simulator natively — this package composes with it rather than reimplementing it.
+
+## Comparison with Unity's Device Simulator
+
+Unity's built-in **Device Simulator** and this package's tooling are **complementary** — in fact this package's panel *lives inside* Unity's Device Simulator.
+
+### What Unity's Device Simulator does (and does well)
+
+- Wraps the Game view in a device frame (~30 device profiles) with the correct screen aspect, notch / dynamic island cutout, and safe-area inset.
+- Overrides Unity-level APIs to match the chosen device: `Screen.safeArea`, `Screen.dpi`, `Screen.width/height`, `Screen.orientation`, `Application.platform`, `SystemInfo.deviceModel`, etc.
+- Triggers `#if UNITY_IOS` / `#if UNITY_ANDROID` runtime branches by spoofing `Application.platform`.
+- Mouse-as-touch input, orientation flip, foreground/background pause toggles.
+
+### What it doesn't do (the gap this package fills)
+
+Native OS surfaces — `UIAlertController`, `UIActivityViewController`, `SKStoreReviewController`, `UNUserNotificationCenter` heads-up banners, the iOS permission prompt, the ATT prompt, Android's Material dialog and Toast — **live in the OS process, not in Unity's renderer**. The Device Simulator can't paint them because they don't exist in the editor at all. When `NativeUiService.ShowAlertPopUp(...)` is called in the editor, Unity's Device Simulator has nothing to show.
+
+| Surface | Unity Device Simulator | Mobile Services overlay |
+|---------|------------------------|--------------------------|
+| iOS alert / sheet (`ShowAlertPopUp`) | — | iOS-styled mock with the supplied buttons |
+| Android Material dialog | — | mock card with the supplied buttons |
+| Toast (iOS / Android) | — | top-pill / bottom-pill mock |
+| Share sheet | — | iOS grid / Android list mock |
+| Review prompt | — | iOS `SKStoreReviewController` star sheet / Android Play bottom-sheet mock |
+| Permission dialog | — | dialog rendered with project-configured `NSUsageDescription` |
+| ATT dialog | — | dialog rendered with `NSUserTrackingUsageDescription` |
+| Heads-up notification banner | — | iOS / Android heads-up card with app icon, app-name + time header, bold title + body |
+| Device frame + bezels | full library | — |
+| Touch input via mouse | yes | — |
+| `Application.platform` spoofing | yes | — (consumes it to auto-skin) |
+
+The configured-usage-description piece is the strongest unique value: Unity's Device Simulator can't know what text Apple's review team will read for `NSCameraUsageDescription`, but the Mobile Services panel reads it from `MobileServicesConfig` and surfaces it in the dialog mock — making the editor preview match what'll appear on the device.
+
+### Acknowledged overlap
+
+A few pieces of `EditorPlatformSimulator` overlap with Unity's Device Simulator. They earn their keep specifically for **test/automation paths** that Unity's interactive-only simulator doesn't expose.
+
+| Feature | Unity Device Simulator | Why this package still ships it |
+|---------|------------------------|--------------------------------|
+| `EditorPlatformSimulator.SetSafeArea` | Richer (real device-accurate cutouts via device picker) | Programmatic API — drives `SafeAreaService.OnSafeAreaChanged` deterministically from unit tests |
+| `EditorPlatformSimulator.SetIosLowPowerMode` | "Low Battery" toggle in newer versions | Programmatic — fires `BatteryService.OnLowPowerModeChanged` for tests |
+
+If your iteration loop is interactive (designer-paired phone or just clicking around), Unity's Device Simulator wins for safe-area / platform / device-frame work. If your iteration loop is scripted (CI tests, automated previews), the `EditorPlatformSimulator` API is the path. Use both — they don't conflict.
+
+## Recommended workflow
+
+1. Open Unity's Device Simulator (`Window > General > Device Simulator`) and pick a target device. The **Mobile Services** panel appears in the Control Panel automatically.
+2. Fire mocks from the panel — they render inside the simulated phone screen immediately, in edit mode. No play-mode round-trip and no second window to dock.
+3. Press Play and call `RequestAsync()` / `RequestAuthorizationAsync()` from your consumer to see the OS prompt render in the overlay (or resolve a pending prompt from the panel's Allow / Don't Allow fallback); the last-gesture read-out also needs Play mode.
+4. For scripted automation / CI: skip the UI and call `EditorPlatformSimulator.Engage()` + `Set*` / `Reset*` / `ResolvePending*` directly from your test setup.
+
+## When to use which
+
+| Need | Use |
+|------|-----|
+| Preview what an iOS alert / toast / share will look like (no play mode) | Mobile Services panel Native UI buttons — fire the mock into the overlay |
+| See the iOS / Android review prompt mock | Call `NativeUiService.RequestReview()` from your game in play mode (simulator engaged) — like ATT / Permissions, there is no manual panel trigger |
+| Render the mock inside the simulated phone screen at correct scale | Device Simulator device profile + the overlay (alive while the panel is open) |
+| Watch live service state during play mode | Mobile Services panel diagnostics (Play mode) |
+| Set a permission / ATT state your running game reads | Mobile Services panel — the Permissions / ATT state dropdowns (the Settings surface) |
+| See the OS permission / ATT prompt the first time your game requests | Leave the state `NotDetermined`, press Play, call `RequestAsync()` — the prompt renders in the overlay |
+| Drive a permission Request result for a unit test | `EditorPlatformSimulator.SetPermissionState` (cached) or leave `NotDetermined` + `ResolvePendingPermissionPrompt` |
+| Test that your code subscribes to `OnLowPowerModeChanged` correctly | `EditorPlatformSimulator.SetIosLowPowerMode(true, batteryService)` |
+| Demo a deep link routing flow without launching from the OS | DeepLinkRouter sample, or `EditorPlatformSimulator.SimulateDeepLink(uri, yourService)` |
diff --git a/docs/explorer.md.meta b/docs/explorer.md.meta
new file mode 100644
index 0000000..e66d4bb
--- /dev/null
+++ b/docs/explorer.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 3acb8ea3cf0bd416e8805650c9ce2b54
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/gestures.md b/docs/gestures.md
new file mode 100644
index 0000000..dfe16bc
--- /dev/null
+++ b/docs/gestures.md
@@ -0,0 +1,73 @@
+# Gestures
+
+`GestureController` is a `MonoBehaviour` that subscribes to Unity's `EnhancedTouch` API and emits swipe / tap events.
+
+## Setup
+
+1. Add `GestureController` to any scene GameObject.
+2. Subscribe to its events:
+
+```csharp
+_gestureController.Pressed             += swipe => { /* on finger down */ };
+_gestureController.PotentiallySwiped   += swipe => { /* every move; valid-swipe candidate */ };
+_gestureController.Swiped              += swipe => { /* on finger up, IsValidSwipe */ };
+_gestureController.Tapped              += tap   => { /* on finger up, IsValidTap */ };
+```
+
+3. In editor, add a `TouchSimulation` component to a GameObject if you want mouse-as-touch in the editor.
+
+`EnhancedTouchSupport` is enabled in `OnEnable` and disabled in `OnDisable` — no manual lifecycle management needed.
+
+## Thresholds
+
+`GestureController` exposes five `[SerializeField]` thresholds:
+
+| Field | Default | Meaning |
+|-------|---------|---------|
+| `_maxTapDuration` | `0.2f` | Press > this is not a tap |
+| `_maxTapDrift` | `5.0f` | Drift in screen units > this is not a tap |
+| `_maxSwipeDuration` | `0.5f` | Swipes longer than this don't qualify |
+| `_minSwipeDistance` | `10.0f` | Movement under this is not a swipe |
+| `_swipeDirectionSamenessThreshold` | `0.6f` | Swipes must be consistently in one direction this fraction of the time |
+
+**Important**: if `_minSwipeDistance <= _maxTapDrift`, a single interaction can qualify as BOTH a tap and a swipe — both events fire. Tune carefully (or treat the dual case intentionally).
+
+## SwipeInput
+
+```csharp
+public readonly struct SwipeInput
+{
+    public SwipeDirection SwipeDirection;  // Up / Down / Left / Right
+    public float SwipeVelocity;            // speed of the gesture
+    public float SwipeSameness;            // direction consistency 0–1 (higher = cleaner)
+    public Vector2 StartPosition;
+    public Vector2 EndPosition;
+}
+```
+
+## TapInput
+
+```csharp
+public struct TapInput
+{
+    public readonly Vector2 PressPosition;
+    public readonly Vector2 ReleasePosition;
+    public readonly double TapDuration;
+    public readonly float TapDrift;
+    public readonly double TimeStamp;
+}
+```
+
+## Debug overlay
+
+`GestureController` has a `[SerializeField] Text _label` ("Debug" header). Assigning it surfaces per-gesture metrics live — useful for tuning the thresholds. Leave null in production.
+
+## NOT in the umbrella facade
+
+The `IMobileService` umbrella doesn't expose Gestures — `GestureController` is a per-scene MonoBehaviour, not a service-locator-style service. Add it to scenes that need it; subscribe from your input controller.
+
+## Editor
+
+- Editor pointer simulation requires `TouchSimulation` on a scene GameObject (Unity Input System feature).
+- The Device Simulator panel's **Gestures** foldout finds the live `GestureController` via `Object.FindFirstObjectByType` and surfaces the last-detected swipe + tap metrics.
+- End-to-end gesture detection isn't unit-tested — it requires the Input System's `InputTestFixture`. Only the math (`ActiveGesture` / `SwipeInput` / `TapInput`) is covered. `GestureController` has a smoke test verifying subscribe/unsubscribe lifecycle.
diff --git a/docs/gestures.md.meta b/docs/gestures.md.meta
new file mode 100644
index 0000000..26dcfc1
--- /dev/null
+++ b/docs/gestures.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f83e7afa0fee041e280fa1d664d92460
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/haptics.md b/docs/haptics.md
new file mode 100644
index 0000000..763626e
--- /dev/null
+++ b/docs/haptics.md
@@ -0,0 +1,85 @@
+# Haptics
+
+Zero-dependency haptic feedback. Built directly on iOS `UI*FeedbackGenerator` (iOS 10+) and Android `VibrationEffect.createWaveform` (API 26+) — no NiceVibrations, no Lofelt-runtime.
+
+## API
+
+```csharp
+public interface IHapticsService
+{
+    bool Enabled { get; set; }     // master toggle; setting false stops any active haptic
+    bool IsSupported { get; }      // device + OS can produce at least basic vibration
+    bool IsPlaying { get; }        // between any Play* and the matching stop
+
+    void PlayPreset(HapticPreset preset);                              // natural one-shot
+    void PlayPresetDuration(HapticPreset preset, float duration = -1f); // 0 = natural, <0 = loop, >0 = loop+auto-stop
+    void PlayCustom(float intensity01, float durationMs);               // single intensity, always finite
+    void StopCurrentHaptic();                                           // single stop entry point, idempotent
+}
+```
+
+## Preset catalogue
+
+```csharp
+public enum HapticPreset
+{
+    None = 0,
+    Selection = 1,        // crisp tick (picker / discrete value change)
+    Success = 2,          // two-tap ascending notification
+    Warning = 3,          // single warning tap
+    Error = 4,            // multi-tap error
+    ImpactLight = 5,      // soft low-amplitude impact
+    ImpactMedium = 6,     // default impact
+    ImpactHeavy = 7,      // strong impact
+    ImpactRigid = 8,      // sharp short impact (snappy)
+    ImpactSoft = 9,       // gentle longer impact (cushioned)
+}
+```
+
+The per-preset `(timings_ms, amplitudes_0_to_255)` envelopes live in `Runtime/Haptics/Internal/HapticEnvelopes.cs` and feed both `AndroidHapticsBackend` and the [Device Simulator panel](explorer.md)'s Haptics envelope graph — single source of truth.
+
+## Looping semantics
+
+`PlayPresetDuration(preset, duration)`:
+
+- `duration == 0f` — play the preset's natural one-shot duration. Same as `PlayPreset(preset)`.
+- `duration < 0f` (default `-1f`) — loop indefinitely. **Caller MUST invoke `StopCurrentHaptic`** (or `Enabled = false`) to end it.
+- `duration > 0f` — loop the preset, auto-stop after `duration` real-time seconds (unaffected by `Time.timeScale`).
+
+Each new `Play*` call cancels any previously pending auto-stop coroutine — only one auto-stop is ever pending. `HapticsHost` (the internal MonoBehaviour) is spawned lazily on the first play with auto-stop.
+
+## Custom intensity
+
+```csharp
+haptics.PlayCustom(intensity01: 0.7f, durationMs: 250f);
+```
+
+Always finite; `durationMs <= 0` is a no-op. Intensity is clamped to `[0, 1]`. On Android this routes through `VibrationEffect.createOneShot(milliseconds, amplitude0to255)`. On iOS, the bridge maps the intensity to the closest UIKit feedback generator on a discrete amplitude curve.
+
+## Platform support
+
+| Platform | Backend |
+|----------|---------|
+| iOS | `IosHapticsBackend` — UIKit `UIImpactFeedbackGenerator` / `UINotificationFeedbackGenerator` / `UISelectionFeedbackGenerator`. Looping via `NSTimer`. |
+| Android | `AndroidHapticsBackend` — pure JNI `Vibrator.vibrate(VibrationEffect)`. Requires API 26 (Android 8.0)+. |
+| Editor | `EditorHapticsBackend` — logs only. |
+| Other (Standalone, WebGL) | `NoOpHapticsBackend` — `IsSupported = false`. |
+
+The backend is selected at construction by `CreateDefaultBackend()`. Tests construct `HapticsService` with the internal injection constructor + a `FakeHapticsBackend`.
+
+## Editor introspection
+
+Internal accessors on `HapticsService` (visible to the Editor assembly via `InternalsVisibleTo`):
+
+- `CurrentPreset` — the preset most recently passed to a `Play*`. `HapticPreset.None` when not playing or after a `PlayCustom`.
+- `CurrentDurationSeconds` — scheduled duration in real-time seconds (preset's natural duration for one-shots, `-1` for indefinite loop, explicit positive for finite).
+- `Backend` — the platform backend selected for this instance.
+
+These power the Device Simulator panel's **Haptics** status read-out.
+
+## Editor testing on a real device
+
+In the editor, the `EditorHapticsBackend` only logs. To test haptic feel on a paired phone:
+
+1. Use Unity Remote (haptics will NOT fire through Unity Remote — it only relays input/display).
+2. Or deploy a debug build to the device for the iteration loop. The `HapticsPalette` sample is designed for this loop.
diff --git a/docs/haptics.md.meta b/docs/haptics.md.meta
new file mode 100644
index 0000000..109432d
--- /dev/null
+++ b/docs/haptics.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 51e9f9103876f46e9afbc105a64801d8
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/native-ui.md b/docs/native-ui.md
new file mode 100644
index 0000000..d5d7d6e
--- /dev/null
+++ b/docs/native-ui.md
@@ -0,0 +1,73 @@
+# Native UI
+
+Static `NativeUiService` + instance-based `INativeUiService` wrapper. iOS bridge in `Plugins/iOS/NativeUi.m`, Android bridge via `AndroidJavaObject`.
+
+## Static API
+
+```csharp
+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();
+
+NativeUiService.Share(text: "Check out my high score!", url: "https://example.com/game");
+```
+
+## Instance API
+
+For mock-friendly consumer code:
+
+```csharp
+INativeUiService ui = new NativeUiServiceInstance();
+ui.ShowToastMessage("hello", false);
+```
+
+The instance class is a pure forwarder — it holds no fields and is safe to construct any number of times. In tests, substitute the interface (e.g. via NSubstitute).
+
+## Alert buttons
+
+```csharp
+public enum AlertButtonStyle
+{
+    Default,        // iOS: tinted; Android: positive button slot
+    Destructive,    // iOS: red text; Android: negative button slot
+    Cancel,         // iOS: bold cancel; Android: neutral button slot
+}
+```
+
+The `AlertButton.Callback` action fires when the user taps that button. Callbacks are matched **by button text** on iOS — keep button texts unique within a single alert to avoid ambiguous matches.
+
+## Review prompt
+
+`RequestReview()`:
+
+- **iOS**: `SKStoreReviewController`. Modern `requestReviewInScene:` on iOS 14+, fallback to `requestReview` on iOS 10.3–13. The OS throttles the prompt frequency — calling this every launch is safe.
+- **Android**: Play Core `ReviewManagerFactory` + `launchReviewFlow`. The `com.google.android.play:review` dependency is **auto-injected at build time** (default ON — see [build pipeline](build-pipeline.md)), so no manual `mainTemplate.gradle` editing is needed. It never throws.
+
+**Fire-and-forget — no store-page fallback.** Neither platform exposes a "was actually shown" signal: the OS may silently suppress the prompt under its own throttling quota, and that is normal, not an error. Because there is no success callback, the package **logs when the prompt is requested**:
+
+- **iOS** — a `Debug.Log` when `RequestReview()` is called (StoreKit gives no callback at all).
+- **Android** — a `Debug.Log` when the Play review flow is launched. When the Play flow genuinely cannot run (Play Core missing, the request flow returns an unsuccessful task, or launch throws), it is logged as a **warning / error** instead.
+
+The prompt never appears in TestFlight builds, and the OS quota means it will often not appear even in production — do not gate game logic on it having been shown.
+
+## Share sheet
+
+`Share(text, url, imagePath, title)`:
+
+- **iOS**: `UIActivityViewController`. `title` is ignored (iOS doesn't surface a sheet title).
+- **Android**: `Intent.ACTION_SEND` via `Intent.createChooser`. `title` is the chooser title.
+
+Any combination of `text` / `url` / `imagePath` may be supplied. `imagePath` must be an absolute filesystem path. Nulls are skipped.
+
+## Editor & unsupported platforms
+
+In `UNITY_EDITOR`, every method logs to console and returns. On unsupported platforms (Standalone, WebGL), `ShowAlertPopUp` and `ShowToastMessage` throw `SystemException`; `RequestReview` and `Share` are safe no-ops.
+
+The [Device Simulator panel](explorer.md) renders platform-shaped mocks for every native UI surface so designers / engineers can iterate in the editor (edit or play mode) without device builds.
diff --git a/docs/native-ui.md.meta b/docs/native-ui.md.meta
new file mode 100644
index 0000000..f58e7e0
--- /dev/null
+++ b/docs/native-ui.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 61f7f35c3b5c04538b15780988e89a8d
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/notifications.md b/docs/notifications.md
new file mode 100644
index 0000000..26c0220
--- /dev/null
+++ b/docs/notifications.md
@@ -0,0 +1,96 @@
+# Notifications
+
+`INotificationService` / `MobileNotificationService` wrap Unity's `com.unity.mobile.notifications` package. The service creates a `DontDestroyOnLoad` `GameObject("NotificationService")` carrying a `GameNotificationsMonoBehaviour` host on construction.
+
+## Quick start
+
+```csharp
+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")
+    .Body("Your daily reward is waiting!")
+    .Channel("rewards")
+    .BadgeIncrement()
+    .Send();
+```
+
+## Long-form API
+
+```csharp
+var notification = service.CreateNotification();
+notification.Title = "Daily Reward Ready!";
+notification.Body = "Your daily reward is waiting for you!";
+notification.DeliveryTime = DateTime.Now.AddHours(24);
+notification.Channel = "rewards";
+var pending = service.ScheduleNotification(notification);
+
+// pending.Reschedule = true   // re-queue if foregrounded before delivery (Queue + RescheduleAfterClearing)
+
+service.CancelNotification(pending.Notification.Id.Value);
+service.CancelAllScheduledNotifications();
+service.DismissAllDisplayedNotifications();
+
+var scheduled = service.PendingNotifications;  // IReadOnlyList<PendingNotification>
+```
+
+## Channels (Android)
+
+Android requires at least one notification channel registered with the OS before any notification will display (Android 8+). The constructor takes a `params GameNotificationChannel[]`:
+
+```csharp
+new MobileNotificationService(
+    new GameNotificationChannel("default", "Default", "Default notifications"),
+    new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders"));
+```
+
+**The first channel passed becomes the platform default** — notifications scheduled without an explicit `Channel` field land here. On iOS, channels are stored but not exposed to the OS (iOS has no channel concept).
+
+## Operating modes
+
+```csharp
+[Flags]
+public enum OperatingMode
+{
+    NoQueue                   = 0x00,  // default — schedule with OS immediately
+    Queue                     = 0x01,  // hold in memory, only schedule when app backgrounds
+    ClearOnForegrounding      = 0x02,  // clear pending when app foregrounds
+    RescheduleAfterClearing   = 0x04,  // re-queue Reschedule=true entries after a clear (requires ClearOnForegrounding)
+    QueueAndClear             = Queue | ClearOnForegrounding,
+    QueueClearAndReschedule   = Queue | ClearOnForegrounding | RescheduleAfterClearing,
+}
+```
+
+Set the mode via `INotificationService.Mode` (defaults to `NoQueue`). It's settable at runtime and the new value takes effect on the next `ScheduleNotification` call and the next foreground/background transition:
+
+```csharp
+service.Mode = OperatingMode.QueueClearAndReschedule;
+```
+
+`OnLocalNotificationExpiredEvent` only fires while a `Queue` mode is active, so set the mode before scheduling if you rely on it. The `NotificationsScheduler` sample drives this live.
+
+## Foreground delivery events
+
+```csharp
+service.OnLocalNotificationDeliveredEvent += pending =>
+    Debug.Log($"Delivered while foreground: {pending.Notification.Title}");
+service.OnLocalNotificationExpiredEvent += pending =>
+    Debug.Log($"Cleared because foregrounded: {pending.Notification.Title}");
+```
+
+`OnLocalNotificationDeliveredEvent` fires when the OS hands the notification to the app while it's in the foreground (typical for iOS — Android usually displays the heads-up regardless). `OnLocalNotificationExpiredEvent` fires when `ClearOnForegrounding` cancelled a queued notification because the user was already inside the app at delivery time.
+
+## Editor behaviour
+
+In `UNITY_EDITOR`:
+
+- `CreateNotification` returns an `EditorGameNotification` (in-memory POCO).
+- `ScheduleNotification` assigns a hashed-`DateTime` id if `Id == null` and returns a `PendingNotification` wrapper — the OS layer is NOT touched.
+- The [Device Simulator panel](explorer.md)'s **Notifications** foldout + the in-Game-view simulator overlay combine to give you a banner-mock preview at the simulated delivery time, driven by the editor's update loop.
+
+## Teardown
+
+The host GameObject is `DontDestroyOnLoad`. Tests / "reset game" flows that destroy the DDOL scene need to recreate the service afterwards. There is no explicit `Dispose` — destroy `GameObject.Find("NotificationService")` if you need to tear down.
diff --git a/docs/notifications.md.meta b/docs/notifications.md.meta
new file mode 100644
index 0000000..1f3000a
--- /dev/null
+++ b/docs/notifications.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 6a71e0cbe7ee544648c5cd05d03f563b
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/samples.md b/docs/samples.md
new file mode 100644
index 0000000..7206a2b
--- /dev/null
+++ b/docs/samples.md
@@ -0,0 +1,28 @@
+# Samples Index
+
+Four code-only samples ship with the package. Each is a single C# MonoBehaviour that builds a runtime UI canvas via legacy `UnityEngine.UI` — drop it on a GameObject, press Play. No `.unity` scene file, no `.prefab`, no asset references.
+
+| Sample | Folder | Purpose |
+|--------|--------|---------|
+| Mobile Services Playground | [`Samples~/MobileServicesPlayground`](../Samples~/MobileServicesPlayground/README.md) | Kitchen-sink wiring proof. Buttons for every native UI / haptic / notification / permission / ATT / deep-link call. |
+| Haptics Palette | [`Samples~/HapticsPalette`](../Samples~/HapticsPalette/README.md) | Designer iteration tool. 3x3 preset grid + sequence recorder + replay. |
+| Notifications Scheduler | [`Samples~/NotificationsScheduler`](../Samples~/NotificationsScheduler/README.md) | Lifecycle demo. Channel CRUD, `OperatingMode` toggles, background-foreground round trip. |
+| Deep Link Router | [`Samples~/DeepLinkRouter`](../Samples~/DeepLinkRouter/README.md) | `IDeepLinkRouter.MapRoute` demo with three routes and cold-start replay instructions. |
+
+## Importing
+
+`Window > Package Manager > GameLovers.MobileServices > Samples > <sample-name>` — imports into `Assets/Samples/Mobile Services/<version>/<sample-name>/`.
+
+## Sample-only types
+
+All sample types live in `GameLovers.MobileServices.Samples.<Name>` namespaces and are **NOT** part of the public package API surface. When updating any sample's README or the main package README, never describe these types as if they were package API.
+
+## Why code-only
+
+Divergence from peer `com.gamelovers.services` / `com.gamelovers.uiservice` which ship `.unity` + `.prefab` files with hand-authored deterministic GUIDs.
+
+- Zero asset dependencies — no `.unity` scenes, no `.prefab` files, no deterministic-GUID `.meta` files to keep in sync.
+- Diff-friendly — code reviewers see exactly what the sample does at the source level.
+- Easy to drop into any scene — no "now open `<sample>.unity`" step.
+
+The trade-off is no built-in scene hierarchy or prefab structure for the user to inspect; acceptable for the mobile surface since most behaviour is fired by buttons, not configured by serialised state.
diff --git a/docs/samples.md.meta b/docs/samples.md.meta
new file mode 100644
index 0000000..bdd6781
--- /dev/null
+++ b/docs/samples.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a3841e8127a8543fe8e719cbbd6c8e2a
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..43b2fcc
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,67 @@
+# Troubleshooting
+
+Symptom-to-fix mapping for the documented behaviours and gotchas. For architecture details see [AGENTS.md](../AGENTS.md) §4.
+
+## Native UI
+
+| Symptom | Fix |
+|---------|-----|
+| `ShowAlertPopUp` does nothing in editor | Editor short-circuit logs to console. Open `Window > General > Device Simulator` and use the [Mobile Services panel](explorer.md) to preview platform-shaped mocks inside the simulated phone. |
+| `ShowAlertPopUp` throws `SystemException` | Running on an unsupported platform (Standalone, WebGL). Mobile-only API. |
+| iOS alert button callback fires the wrong handler | Two buttons in the same alert share their `Text`. iOS matches by text — keep button texts unique within a single alert. |
+| `RequestReview()` does nothing on Android | Missing `com.google.android.play:review:2.0.1` in `mainTemplate.gradle`. The call logs an error and returns; it does NOT throw. |
+| `RequestReview()` doesn't show the prompt every time | Working as intended — both iOS `SKStoreReviewController` and Play In-App Review throttle the prompt frequency. |
+
+## Notifications
+
+| Symptom | Fix |
+|---------|-----|
+| Android notification never displays | At least one `GameNotificationChannel` must be registered in the `MobileNotificationService` constructor. First channel passed becomes the default. |
+| `ScheduleNotification` returns a PendingNotification but the OS never delivers | Editor short-circuit — `EditorGameNotification` is in-memory only. Build to a device. |
+| Queued notification doesn't fire when app backgrounds | `OperatingMode` must include `Queue` for queueing behaviour. Default is `NoQueue` (schedule with OS immediately). |
+| `RescheduleAfterClearing` doesn't re-queue | Set `pending.Reschedule = true` on the returned `PendingNotification`. Also requires `ClearOnForegrounding` in the mode. |
+| Notification host survives "reset game" flow | The `NotificationService` GameObject is `DontDestroyOnLoad`. Tests / reset flows must destroy it manually. |
+
+## Gestures
+
+| Symptom | Fix |
+|---------|-----|
+| `Swiped` event never fires | `EnhancedTouchSupport` not enabled — but `GestureController` enables/disables it in `OnEnable`/`OnDisable`. Check the controller component is active. |
+| Mouse clicks don't trigger swipes/taps in editor | Add a `TouchSimulation` component to any GameObject in the scene. |
+| Same gesture fires BOTH `Tapped` and `Swiped` | `_minSwipeDistance <= _maxTapDrift` — tune the thresholds. |
+
+## Haptics
+
+| Symptom | Fix |
+|---------|-----|
+| `PlayPreset` is silent on a paired phone via Unity Remote | Unity Remote relays input/display only — haptics don't transmit. Build a debug player to the device. |
+| `PlayPresetDuration(preset, -1f)` keeps vibrating forever | By design — `-1` is "loop until `StopCurrentHaptic`". Set `Enabled = false` or call `StopCurrentHaptic`. |
+| `PlayCustom` throws "DontDestroyOnLoad can only be used in play mode" in EditMode tests | `PlayCustom` always schedules an auto-stop, which spawns the `HapticsHost` GameObject. Move the test to PlayMode. |
+| Haptic continues after "reset game" | Reset flow destroys the `HapticsHost` GameObject without stopping the haptic first. Call `StopCurrentHaptic()` before reset. |
+
+## Device subsystem
+
+| Symptom | Fix |
+|---------|-----|
+| `IsLowPowerMode` is always false in editor | Editor short-circuits LPM detection. Use `EditorPlatformSimulator.SetIosLowPowerMode(true, battery)` to drive the change. |
+| `device.Att.CurrentStatus` returns `Authorized` on Android | Android has no ATT equivalent — the service returns `Authorized` unconditionally. Don't read this as "the user authorized"; gate tracking-init on `Application.platform == RuntimePlatform.IPhonePlayer`. |
+| Cold-start deep link is lost | Construct `DeepLinkService` early — before scene load. `Application.absoluteURL` is cleared by Unity once consumed. |
+| Second subscriber to `OnLinkActivated` doesn't receive the cold-start link | By design — the link represents a single user action, replayed to the FIRST subscriber only. |
+
+## Permissions
+
+| Symptom | Fix |
+|---------|-----|
+| `RequestAsync` resolves to `Granted` in editor without prompting | Editor short-circuits to `Granted`. Use `EditorPlatformSimulator.QueuePermissionResult(...)` to override. |
+| `Permission.READ_MEDIA_IMAGES` denied on Android < 13 | The permission is auto-granted below API 33. The service returns `Granted` immediately via Unity's `Permission.HasUserAuthorizedPermission` short-circuit. Add the legacy `READ_EXTERNAL_STORAGE` for that path. |
+| iOS Camera prompt shows blank text | Missing `NSCameraUsageDescription`. Open `Tools > GameLovers > Mobile Services > Select Mobile Services Config` or rely on the build postprocessor to fail the build (default). |
+
+## Editor / Build
+
+| Symptom | Fix |
+|---------|-----|
+| iOS build fails with `[GameLovers.MobileServices] iOS build failed because…` | A referenced permission has an empty usage description. Open `Tools > GameLovers > Mobile Services > Select Mobile Services Config` and fill the missing field (the `Fill missing English descriptions with suggested copy` button is a quick start), or enable `Manage Native Build Manually` if you manage `Info.plist` yourself. |
+| Android build doesn't pick up `<uses-permission>` entries | Your project lacks `Assets/Plugins/Android/mainTemplate.xml`. Enable `Player Settings > Publishing Settings > Custom Main Manifest` and rebuild. |
+| The Mobile Services panel is missing from the Device Simulator | Open `Window > General > Device Simulator` and look in the Control Panel column. The panel is auto-discovered; if it's absent, ensure the `GameLovers.MobileServices.Editor` assembly compiled (check the Console for errors). |
+| Mocks fired from the panel don't render | The overlay paints into the Game / Simulator view — keep the Device Simulator window open (the overlay is alive only while the Device Simulator panel is open). |
+| Simulator overlay shows generic Unity dialogs | The simulator USS files didn't load. Verify `Editor/Explorer/Overlays/MobileSimulator.Common.uss` is present in the package and re-import. |
diff --git a/docs/troubleshooting.md.meta b/docs/troubleshooting.md.meta
new file mode 100644
index 0000000..9828c91
--- /dev/null
+++ b/docs/troubleshooting.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: d9a39b9da7f9f4da891ab5e81f8afacb
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/package.json b/package.json
index 525e61b..372063f 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,36 @@
 {
-  "name": "com.gamelovers.nativeui",
-  "displayName": "Native UI",
-  "version": "0.2.5",
-  "unity": "2020.3",
-  "description": "This package provides the OS functionality to show the native UI alerts, Game Review PopUp and message Toasts",
-  "type": "library"
-}
\ No newline at end of file
+  "name": "com.gamelovers.mobileservices",
+  "displayName": "Mobile Services",
+  "author": "Miguel Tomas",
+  "version": "1.0.0",
+  "unity": "6000.0",
+  "license": "MIT",
+  "description": "Mobile platform services for Unity: native UI (alerts/toasts), push notifications, and gesture detection (swipe/drag).",
+  "type": "library",
+  "dependencies": {
+    "com.unity.mobile.notifications": "2.3.0",
+    "com.unity.inputsystem": "1.11.0"
+  },
+  "samples": [
+    {
+      "displayName": "Mobile Services Playground",
+      "description": "Kitchen-sink scene that wires every Mobile Services subsystem (NativeUI / Haptics / Notifications / Device / Permissions / ATT / DeepLink) into one runtime-built canvas.",
+      "path": "Samples~/MobileServicesPlayground"
+    },
+    {
+      "displayName": "Haptics Palette",
+      "description": "Designer iteration tool: 3x3 preset grid + sequence recorder + replay. Use on a paired device.",
+      "path": "Samples~/HapticsPalette"
+    },
+    {
+      "displayName": "Notifications Scheduler",
+      "description": "Lifecycle demo: channel CRUD, OperatingMode toggles, background-foreground round trip with reschedule semantics.",
+      "path": "Samples~/NotificationsScheduler"
+    },
+    {
+      "displayName": "Deep Link Router",
+      "description": "MapRoute pattern demo: /promo/:id, /profile/:userId, /settings, cold-start replay through IDeepLinkRouter.",
+      "path": "Samples~/DeepLinkRouter"
+    }
+  ]
+}
diff --git a/package.json.meta b/package.json.meta
index 666331e..2d4779b 100644
--- a/package.json.meta
+++ b/package.json.meta
@@ -1,5 +1,5 @@
 fileFormatVersion: 2
-guid: ff1d659c65c69468894701f784996f6c
+guid: afbe3ed05e3ed42a0840e4df73fcd80b
 PackageManifestImporter:
   externalObjects: {}
   userData: 
