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` (no UniTask dep). Android uses `UnityEngine.Android.Permission` with manifest mapping for Camera/Mic/FineLocation; uses `READ_MEDIA_IMAGES` (API 33+) for Photos and `POST_NOTIFICATIONS` for Notifications. iOS uses `Plugins/iOS/Permissions.m` with one bridge per permission (`AVCaptureDevice` for Camera/Mic, `CLLocationManager` for Location, `PHPhotoLibrary` for Photos, `UNUserNotificationCenter` for Notifications). Async results returned via `UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", ":")` to `Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs` which resolves the matching `TaskCompletionSource`. + - **Location delegate lifetime**: iOS bridge keeps `CLLocationManager` instances alive in a static `NSMutableArray` so the delegate isn't GC'd before `locationManagerDidChangeAuthorization:` fires. The delegate clears itself from the manager after dispatch. +- **App Tracking Transparency**: `Runtime/Device/Tracking/IAttService.cs` + `AttService.cs`. iOS bridge: `Plugins/iOS/Att.m` calling `ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler:` (iOS 14+ only — pre-14 returns Authorized). Same `UnitySendMessage` callback pattern as Permissions but with a separate `AttCallbackReceiver` MonoBehaviour to keep payload formats per-subsystem. **No dependency on `com.unity.ads.ios-support`** — explicit goal. +- **Deep Links**: `Runtime/Device/DeepLinks/IDeepLinkService.cs` + `DeepLinkService.cs`. Wraps `Application.deepLinkActivated`; on construction captures `Application.absoluteURL` (set by Unity before any subscriber attaches when the app is cold-launched with a link) and replays it to the first subscriber via the `OnLinkActivated` event's `add` accessor. Runtime delivery clears any pending cold-start link. +- **Deep Link Router**: `Runtime/Device/DeepLinks/IDeepLinkRouter.cs` + `DeepLinkRouter.cs`. Layered over `IDeepLinkService`. Path-pattern routing: literal segments match exactly (case-insensitive), `:name` segments capture into a params dict (e.g. `/promo/:id` → `{ "id": "spring2026" }`). First match wins, registration order is preserved. Router subscribes once at construction; consumers hold the router for the lifetime of the app. + +### Gestures (`GameLovers.MobileServices.Gestures`) +- **Input source**: Unity's `EnhancedTouch` API (`Touch.onFingerDown/Move/Up`) +- **Gesture detection** + - `Runtime/Gestures/GestureController.cs` subscribes to EnhancedTouch finger events and emits gesture events (`Pressed`, `PotentiallySwiped`, `Swiped`, `Tapped`). + - `Runtime/Gestures/ActiveGesture.cs` is the internal state accumulator per finger. + - `Runtime/Gestures/SwipeInput.cs` is the public data structure for swipe output. + - `Runtime/Gestures/TapInput.cs` is the public data structure for tap output. + +### Haptics (`GameLovers.MobileServices.Haptics`) +- **Public API**: `Runtime/Haptics/IHapticsService.cs` + `Runtime/Haptics/HapticsService.cs` + - `Enabled`, `IsSupported`, `IsPlaying` + - `PlayPreset(HapticPreset)` — natural one-shot; sugar for `PlayPresetDuration(preset, 0f)` + - `PlayPresetDuration(HapticPreset, float duration = -1f)` — `0`=natural one-shot, `<0`=loop until `StopCurrentHaptic`, `>0`=loop with real-time auto-stop + - `PlayCustom(float intensity01, float durationMs)` — single-intensity haptic, always finite + - `StopCurrentHaptic()` — single stop entry point; idempotent +- **Preset catalogue**: `Runtime/Haptics/HapticPreset.cs` — 9 entries (Selection, Success, Warning, Error, ImpactLight, ImpactMedium, ImpactHeavy, ImpactRigid, ImpactSoft) plus `None`. +- **Backend abstraction**: `Runtime/Haptics/Internal/IHapticsBackend.cs` selects platform impl at construction: + - iOS: `IosHapticsBackend` → `[DllImport("__Internal")]` into `Plugins/iOS/Haptics.m` (UIKit `UIImpactFeedbackGenerator` / `UINotificationFeedbackGenerator` / `UISelectionFeedbackGenerator`); looping via `NSTimer`. + - Android: `AndroidHapticsBackend` → pure JNI to `android.os.Vibrator.vibrate(VibrationEffect)`. `VibrationEffect.createWaveform(long[] timings, int[] amplitudes, int repeat)` for presets; `repeat=0` loops, `cancel()` stops. Requires API 26 (Android 8.0)+. + - Editor: `EditorHapticsBackend` (logs). + - Other: `NoOpHapticsBackend`. +- **Auto-stop**: `Runtime/Haptics/Internal/HapticsHost.cs` (internal MonoBehaviour, lazily spawned on first play, `DontDestroyOnLoad`) runs a single `WaitForSecondsRealtime` coroutine. Each new `Play*` cancels the previous coroutine — only one auto-stop is ever pending. No `ICoroutineService` dependency on `com.gamelovers.services`. +- **Lofelt/NiceVibrations**: `**zero runtime dependency**`. Lofelt code in the demons project was used as inspiration for preset envelope shapes only; every line in this package is original. + +### Editor (`GameLovers.MobileServices.Editor`) +- **Assembly**: `Editor/GameLovers.MobileServices.Editor.asmdef` (`includePlatforms: ["Editor"]`). References the runtime asmdef and the Unity Input System / Notifications packages. +- **Single editor surface — the Device Simulator plugin** (`Editor/Explorer/DeviceSimulatorPanel/`): there is no standalone Explorer or Simulator window anymore (both removed during the 1.0.0 consolidation — the controller-in-one-window / canvas-in-another workflow and the split `WindowPlatform`/`OverlayPlatform` state were the UX problem). `MobileServicesDeviceSimulatorPlugin` is the one place to drive mocks, read live diagnostics, and view the haptic envelope graph; the in-Game-view overlay is the one canvas. + - **`MobileServicesDeviceSimulatorPlugin`** — `UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` subclass auto-discovered by Unity (no menu item, no registration), embedded in the Device Simulator window's Control Panel. Holds only `PermissionsService` + `AttService` instances (for reading state into the dropdowns); a single 500 ms `root.schedule.Execute(...)` poll auto-syncs the platform skin from the device profile (reads `Application.platform`, which the Device Simulator spoofs — robust across Unity 6 minor versions where `DeviceSimulator.deviceChanged` varies) and syncs the state dropdowns. Foldouts: Native UI, Haptics, Notifications (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.` / `…Att`), defaults to `NotDetermined`, and survives editor restarts. Drives runtime services via the `internal` editor hooks documented under §2 below. +- **Editor-only runtime hooks** (consumed only when `UNITY_EDITOR`): `BatteryService.EditorLowPowerModeOverride` + `SimulateLowPowerModeChanged()`, `SafeAreaService.EditorSafeAreaOverride` + `SimulateSafeAreaChanged()`, `DeepLinkService.SimulateLinkActivated(Uri)`, `PermissionsService.EditorCheckOverride` / `EditorRequestOverride` / `EditorRequestAsyncOverride`, `AttService.EditorCurrentStatusOverride` / `EditorRequestResultOverride` / `EditorRequestAsyncOverride`, `NativeUiService.EditorRequestReviewOverride` (an `Action`; fire-and-forget — invoked from the editor branch of `RequestReview` when set, else the plain `Debug.Log` no-op runs). The `EditorRequestAsyncOverride` hooks (a `Func<…, Task<…>>`) let the simulator return a `Task` that completes when the user answers a prompt, and take precedence over the synchronous request override; with no override installed the editor still short-circuits to `Granted` / `Authorized`. All gated behind `#if UNITY_EDITOR` so player builds carry none of this surface. +- **Internal introspection accessors on runtime services** (not part of the public surface; visible to the Editor asm via `InternalsVisibleTo` on `Runtime/AssemblyInfo.cs`): `HapticsService.CurrentPreset` / `CurrentDurationSeconds` / `Backend`; `MobileNotificationService.CurrentMode` / `Channels`; `PermissionsService.CheckSnapshot()`. Add similar `internal` accessors for any new service surfaced in the Device Simulator panel rather than widening public API. +- **Centralised haptic envelopes** (`Runtime/Haptics/Internal/HapticEnvelopes.cs`): the per-preset `(timings, amplitudes)` tables that previously lived only inside the `UNITY_ANDROID && !UNITY_EDITOR` block of `AndroidHapticsBackend` now live in this always-compiled internal class. The Android backend and the Device Simulator panel's envelope graph both read from it — single source of truth. +- **`MobileServicesConfig`** (`Editor/Settings/MobileServicesConfig.cs`): an **editor-only `ScriptableObject` asset** (NOT a `ScriptableSingleton` / `ProjectSettings` — moved in the Unreleased cycle for normal-dev-UX Inspector editing and to match the GameLovers `UiConfigs` / `GoogleSheetImporter` config pattern). A single instance is located ANYWHERE in the project via `MobileServicesConfig.Instance` (cached `AssetDatabase.FindAssets("t:MobileServicesConfig")`; returns a transient in-memory default when no asset exists so build/test reads never NRE) and created/selected via `MobileServicesConfig.GetOrCreateAsset()`. Because the type lives in the Editor assembly the asset is editor-only — `GetOrCreateAsset` defaults it under `Assets/Editor/`; it must stay under an `Editor/` folder so it never ships. Holds per-permission iOS usage descriptions (**per-locale `LocaleEntry` rows — English is the base written to the Info.plist root; every other locale is emitted as `.lproj/InfoPlist.strings`**), ATT usage description (also per-locale), capability toggles, Android manifest opt-ins, the Android `IncludePlayReviewDependency` (default ON) + editable `PlayReviewDependencyCoordinate` (default `com.google.android.play:review:2.0.2`), and the `ManageNativeBuildManually` escape (the single switch that makes the package perform no native-build configuration + skip the fail-fast iOS validation). Setters call `Persist()` (`EditorUtility.SetDirty` + `AssetDatabase.SaveAssets`, no-op for the transient instance). **Removed as over-engineering / redundant**: `ScanPopulatedCapabilities` (dead), `EnableRuntimeSimulatorOverlay` (editor-tooling state, not build config), `Capabilities.BackgroundAudio` (Unity's `Player Settings > iOS > Behavior in Background = Custom > Audio` owns `UIBackgroundModes`), `AllowPlaceholderUsageDescriptions` (validation is now fail-fast; use suggested-copy or the kill-switch), and `BuildCallbackOrder` (`callbackOrder` is hardcoded `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 `.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 `` entries + share-chooser `` block. Android gradle path (`OnPostGenerateGradleAndroidProject`) auto-injects `implementation ''` 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.`). +- **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/.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 `.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 +{ + /// + /// Validates iOS usage descriptions and mutates the post-build Xcode project / Android + /// mainTemplate.xml with the keys + capabilities configured in + /// . See docs/build-pipeline.md for details. + /// + 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 .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 .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(); + 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 = $" "; + if (!contents.Contains($"android:name=\"{perm}\"")) + { + contents = InsertBeforeApplication(contents, line); + changed = true; + } + } + + if (a.IncludeShareQueriesBlock && !contents.Contains("ACTION_SEND")) + { + var queriesBlock = " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " "; + 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(" + /// Auto-injects the Play In-App Review dependency into the generated Gradle project so + /// NativeUiService.RequestReview() 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. + /// + 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 +{ + /// + /// The single Mobile Services editor surface: a that embeds + /// the controls + live diagnostics + haptic envelope graph inside Unity's Device Simulator window + /// (Window > General > Device Simulator), driving the in-Game-view + /// MobileSimulatorRuntimeOverlay via the broker so the + /// mocks render right inside the simulated phone screen (edit mode included). + /// + /// + /// Replaces the former standalone Explorer + Simulator windows. The overlay is kept alive for as + /// long as this panel is open (), + /// 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 Application.platform (spoofed by the Device Simulator), + /// which is robust across Unity 6 minor versions where DeviceSimulator.deviceChanged varies. + /// + 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 _playModeControls = new List(); + private readonly List _editModeBanners = new List(); + + 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 _permStateFields = new Dictionary(); + 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(_ => + { + 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 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 + { + 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(); + 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(); + 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(); + 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(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 +{ + /// + /// Editor-only bootstrap that spawns an in-Game-view overlay - the + /// single simulator canvas - painting the truth-mirror mocks pixel-aligned with the simulated + /// device's Screen.* values. + /// + /// + /// The overlay is alive whenever the Device Simulator plugin panel is open (edit OR play + /// mode) so a designer can fire a mock from the panel and see it inside the simulated phone + /// without entering play mode. A single idempotent drives spawn / + /// teardown. + /// The overlay renders inside Unity's runtime UIToolkit panel - + /// is [ExecuteAlways], so its panel paints into the Game / Device Simulator view in edit + /// mode too. Interaction inside the mock is unreliable in the edit-mode Game view, so dismissal + /// is driven from the plugin panel; the overlay is treated as display-only. + /// The instance is constructed programmatically (rather than + /// shipped as a .asset) to keep the setup editor-only by construction. + /// + [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(); + } + + /// + /// Called by MobileServicesDeviceSimulatorPlugin 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. + /// + 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.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(); + 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()) + { + 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); + } + } + + /// + /// Owns the visual tree + the subscriptions for the + /// overlay. Same renderer surface the standalone window used to provide; the broker payload + /// paints the mocks here. + /// + 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(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 +{ + /// + /// Platform skin for the simulator overlay canvas. Auto-synced from the selected Device + /// Simulator device profile by MobileServicesDeviceSimulatorPlugin; see + /// . + /// + public enum SimulatedPlatform + { + iOS, + Android, + } + + /// + /// Style/role of an alert button rendered by the simulator (mirrors AlertButtonStyle + /// 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). + /// + public enum SimulatedAlertButtonStyle + { + Default, + Destructive, + Cancel, + } + + /// + /// Plain payload describing one mock dialog button surfaced by the overlay. + /// + public sealed class SimulatedAlertButton + { + public string Text; + public SimulatedAlertButtonStyle Style; + public Action OnClicked; + } + + /// + /// Specification of one mock alert dialog. Whether it renders as a centered modal or a bottom + /// action sheet is decided by and the active platform; on Android + /// both shapes collapse onto the same Material 3 dialog mock (no native sheet idiom). + /// + public sealed class SimulatedAlertSpec + { + public string Title; + public string Message; + public bool IsActionSheet; + public List Buttons = new List(); + } + + 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 OnResolved; // true = allow / false = deny + } + + /// + /// Editor-only broker decoupling the Device Simulator plugin and EditorPlatformSimulator + /// from the in-Game-view overlay that paints the mock dialogs. See docs/explorer.md. + /// + /// + /// There is now a single renderer surface (the MobileSimulatorRuntimeOverlay), so push + /// calls are simple broadcasts with no per-target routing, and the platform skin is a single + /// value (auto-synced from the Device Simulator device profile). + /// + 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) ---- + + /// + /// Fires when changes — the master switch that gates every plugin + /// control and shows/hides the in-Game-view simulator banner. + /// + public static event Action EnabledChanged; + + /// + /// 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 . + /// + public static bool Enabled + { + get + { + EnsureInitialized(); + return _enabled; + } + set + { + EnsureInitialized(); + if (_enabled == value) + { + return; + } + _enabled = value; + EditorPrefs.SetBool(EnabledPrefKey, value); + EnabledChanged?.Invoke(value); + } + } + + // ---- Platform ---- + + /// + /// Fires when the changes — the platform skin used by the + /// in-Game-view overlay (auto-synced from Application.platform by the Device + /// Simulator plugin's poll). + /// + public static event Action PlatformChanged; + + /// Platform skin for the simulator overlay. Persisted to . + 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 AlertRequested; + public static event Action ToastRequested; + public static event Action ShareRequested; + public static event Action ReviewRequested; + public static event Action NotificationBannerRequested; + public static event Action 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 +{ + /// + /// Builds the tree for each simulator mock; layout lives in the + /// platform USS files in this folder. + /// + 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(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 onRated) + { + var row = new VisualElement(); + row.AddToClassList("mock-review-stars"); + + var stars = new List