From 76ce624eba37572b3fb7ff295fb7cc241b5f6436 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Tue, 13 Jan 2026 23:02:47 +0000 Subject: [PATCH 01/32] feat(notifications): add local and remote notification management feat(gestures): add swipe and tap detection using Input System refactor(native-ui): migrate to unified namespace and assembly chore(mobile-services): consolidate packages and update to Unity 6 docs(mobile-services): update documentation and add AGENTS.md --- AGENTS.md | 51 ++ AGENTS.md.meta | 7 + CHANGELOG.md | 72 +-- Runtime/Plugins.meta => Plugins.meta | 2 +- {Runtime/Plugins => Plugins}/iOS.meta | 0 {Runtime/Plugins => Plugins}/iOS/NativeUi.m | 0 .../Plugins => Plugins}/iOS/NativeUi.m.meta | 0 README.md | 259 ++------ Runtime/GameLovers.MobileServices.asmdef | 18 + ... => GameLovers.MobileServices.asmdef.meta} | 2 +- Runtime/GameLovers.NativeUi.asmdef | 13 - Runtime/Gestures.meta | 8 + Runtime/Gestures/ActiveGesture.cs | 118 ++++ Runtime/Gestures/ActiveGesture.cs.meta | 2 + Runtime/Gestures/Controls.meta | 8 + Runtime/Gestures/Controls/PointerControls.cs | 603 ++++++++++++++++++ .../Gestures/Controls/PointerControls.cs.meta | 2 + .../Controls/PointerControls.inputactions | 477 ++++++++++++++ .../PointerControls.inputactions.meta | 14 + Runtime/Gestures/GestureController.cs | 183 ++++++ Runtime/Gestures/GestureController.cs.meta | 2 + Runtime/Gestures/PointerInput.cs | 115 ++++ Runtime/Gestures/PointerInput.cs.meta | 2 + Runtime/Gestures/PointerInputManager.cs | 126 ++++ Runtime/Gestures/PointerInputManager.cs.meta | 2 + Runtime/Gestures/SwipeInput.cs | 77 +++ Runtime/Gestures/SwipeInput.cs.meta | 2 + Runtime/NativeUi.meta | 8 + Runtime/{ => NativeUi}/NativeUiService.cs | 2 +- Runtime/NativeUi/NativeUiService.cs.meta | 2 + Runtime/Notifications.meta | 8 + .../Android/AndroidGameNotification.cs | 114 ++++ .../Android/AndroidGameNotification.cs.meta} | 2 +- .../Android/AndroidNotificationsPlatform.cs | 182 ++++++ .../AndroidNotificationsPlatform.cs.meta | 11 + .../Notifications/GameNotificationChannel.cs | 150 +++++ .../GameNotificationChannel.cs.meta | 2 + .../GameNotificationsMonoBehaviour.cs | 537 ++++++++++++++++ .../GameNotificationsMonoBehaviour.cs.meta | 2 + Runtime/Notifications/IGameNotification.cs | 83 +++ .../Notifications/IGameNotification.cs.meta | 2 + Runtime/Notifications/Internal.meta | 8 + .../Internal/EditorGameNotification.cs | 35 + .../Internal/EditorGameNotification.cs.meta | 3 + .../Internal/IGameNotificationsPlatform.cs | 85 +++ .../IGameNotificationsPlatform.cs.meta | 11 + .../Internal/SerializableNotification.cs | 58 ++ .../Internal/SerializableNotification.cs.meta | 3 + .../MobileNotificationService.cs | 140 ++++ .../MobileNotificationService.cs.meta | 2 + Runtime/Notifications/PendingNotification.cs | 41 ++ .../Notifications/PendingNotification.cs.meta | 2 + Runtime/Notifications/iOS.meta | 8 + .../Notifications/iOS/iOSGameNotification.cs | 182 ++++++ .../iOS/iOSGameNotification.cs.meta | 11 + .../iOS/iOSNotificationsPlatform.cs | 126 ++++ .../iOS/iOSNotificationsPlatform.cs.meta | 11 + package.json | 20 +- 58 files changed, 3744 insertions(+), 272 deletions(-) create mode 100644 AGENTS.md create mode 100644 AGENTS.md.meta rename Runtime/Plugins.meta => Plugins.meta (77%) rename {Runtime/Plugins => Plugins}/iOS.meta (100%) rename {Runtime/Plugins => Plugins}/iOS/NativeUi.m (100%) rename {Runtime/Plugins => Plugins}/iOS/NativeUi.m.meta (100%) create mode 100644 Runtime/GameLovers.MobileServices.asmdef rename Runtime/{GameLovers.NativeUi.asmdef.meta => GameLovers.MobileServices.asmdef.meta} (76%) delete mode 100644 Runtime/GameLovers.NativeUi.asmdef create mode 100644 Runtime/Gestures.meta create mode 100755 Runtime/Gestures/ActiveGesture.cs create mode 100644 Runtime/Gestures/ActiveGesture.cs.meta create mode 100644 Runtime/Gestures/Controls.meta create mode 100755 Runtime/Gestures/Controls/PointerControls.cs create mode 100644 Runtime/Gestures/Controls/PointerControls.cs.meta create mode 100755 Runtime/Gestures/Controls/PointerControls.inputactions create mode 100644 Runtime/Gestures/Controls/PointerControls.inputactions.meta create mode 100755 Runtime/Gestures/GestureController.cs create mode 100644 Runtime/Gestures/GestureController.cs.meta create mode 100755 Runtime/Gestures/PointerInput.cs create mode 100644 Runtime/Gestures/PointerInput.cs.meta create mode 100755 Runtime/Gestures/PointerInputManager.cs create mode 100644 Runtime/Gestures/PointerInputManager.cs.meta create mode 100755 Runtime/Gestures/SwipeInput.cs create mode 100644 Runtime/Gestures/SwipeInput.cs.meta create mode 100644 Runtime/NativeUi.meta rename Runtime/{ => NativeUi}/NativeUiService.cs (99%) create mode 100644 Runtime/NativeUi/NativeUiService.cs.meta create mode 100644 Runtime/Notifications.meta create mode 100644 Runtime/Notifications/Android/AndroidGameNotification.cs rename Runtime/{NativeUiService.cs.meta => Notifications/Android/AndroidGameNotification.cs.meta} (83%) create mode 100644 Runtime/Notifications/Android/AndroidNotificationsPlatform.cs create mode 100644 Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta create mode 100644 Runtime/Notifications/GameNotificationChannel.cs create mode 100644 Runtime/Notifications/GameNotificationChannel.cs.meta create mode 100644 Runtime/Notifications/GameNotificationsMonoBehaviour.cs create mode 100644 Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta create mode 100644 Runtime/Notifications/IGameNotification.cs create mode 100644 Runtime/Notifications/IGameNotification.cs.meta create mode 100644 Runtime/Notifications/Internal.meta create mode 100644 Runtime/Notifications/Internal/EditorGameNotification.cs create mode 100644 Runtime/Notifications/Internal/EditorGameNotification.cs.meta create mode 100644 Runtime/Notifications/Internal/IGameNotificationsPlatform.cs create mode 100644 Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta create mode 100644 Runtime/Notifications/Internal/SerializableNotification.cs create mode 100644 Runtime/Notifications/Internal/SerializableNotification.cs.meta create mode 100644 Runtime/Notifications/MobileNotificationService.cs create mode 100644 Runtime/Notifications/MobileNotificationService.cs.meta create mode 100644 Runtime/Notifications/PendingNotification.cs create mode 100644 Runtime/Notifications/PendingNotification.cs.meta create mode 100644 Runtime/Notifications/iOS.meta create mode 100644 Runtime/Notifications/iOS/iOSGameNotification.cs create mode 100644 Runtime/Notifications/iOS/iOSGameNotification.cs.meta create mode 100644 Runtime/Notifications/iOS/iOSNotificationsPlatform.cs create mode 100644 Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5413c82 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# GameLovers.MobileServices - AI Agent Guide + +## 1. Package Overview +- **Package**: `com.gamelovers.mobileservices` +- **Unity**: 6000.0+ +- **Dependencies**: + - `com.unity.mobile.notifications` (2.3.0) + - `com.unity.inputsystem` (1.11.0) + +This package consolidates mobile-specific platform services: Native UI integration, Push Notifications, and Advanced Gesture detection. + +## 2. Runtime Architecture + +### Native UI (`Runtime/NativeUi/`) +- **Main Class**: `NativeUiService` (static) +- **Responsibility**: Bridge to native iOS and Android UI components (Alerts, Sheets, Toasts). +- **Implementation**: Uses `AndroidJavaClass` for Android and `[DllImport("__Internal")]` for iOS (linked via `Plugins/iOS/NativeUi.m`). + +### Notifications (`Runtime/Notifications/`) +- **Main Interface**: `INotificationService` +- **Concrete Class**: `MobileNotificationService` +- **Responsibility**: Wrapper around Unity's Mobile Notifications package. +- **Key Flow**: + - `MobileNotificationService` spawns a `GameNotificationsMonoBehaviour` host GameObject. + - Channels must be configured during initialization. + - Supports platform-specific notification shapes via `IGameNotification`. + +### Gestures (`Runtime/Gestures/`) +- **Main Class**: `GestureController` (MonoBehaviour) +- **Responsibility**: Interprets pointer input to detect complex gestures (Swipes). +- **Key Concepts**: + - `ActiveGesture`: Internal state tracking for a single pointer. + - `SwipeInput`: Data structure containing swipe direction, velocity, and "sameness" (consistency). + - Uses `PointerInputManager` to abstract Input System pointer data. + +## 3. Directory Structure +- `Runtime/NativeUi/`: Native bridge code for C#. +- `Runtime/Notifications/`: Notification management logic. +- `Runtime/Gestures/`: Gesture detection algorithms and Input System integration. +- `Plugins/iOS/`: Native Objective-C code for iOS bridging. +- `Tests/`: Unit and integration tests. + +## 4. Coding Standards +- **Namespaces**: Use `GameLovers.MobileServices.*` sub-namespaces. +- **Platform Defines**: Use `#if UNITY_IOS`, `#if UNITY_ANDROID`, and `#if UNITY_EDITOR` appropriately. +- **Async**: Favor async/await where possible, though many native calls are synchronous or event-based. + +## 5. Migration Logic +This package replaces `com.gamelovers.nativeui`, `com.gamelovers.notificationservice`, and parts of `com.gamelovers.inputextensions`. +- Always update old references to use the new unified assembly: `GameLovers.MobileServices`. +- Namespace mapping is documented in `MIGRATION.md`. diff --git a/AGENTS.md.meta b/AGENTS.md.meta new file mode 100644 index 0000000..bdf1846 --- /dev/null +++ b/AGENTS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ae4ccf446e77f43afb0abc7ed3a02553 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c8aa8..55da45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,59 +1,29 @@ # 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 +All notable changes to this package will be documented in this file. -## [0.1.2] - 2020-08-02 +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). -**Fixed**: -- Package now working properly on Android +## [1.0.0] - 2026-01-13 -## [0.1.1] - 2020-07-31 +### Added +- Initial release of consolidated **Mobile Services** package. +- **Native UI**: Alerts, sheets, and toasts for iOS/Android. +- **Notifications**: Comprehensive local and remote notification management. +- **Gestures**: Advanced swipe detection with velocity and consistency tracking. -**Fixed**: -- Package now working properly on iOS +### Changed +- Refactored all namespaces to `GameLovers.MobileServices.*`. +- Updated assembly definition to `GameLovers.MobileServices`. +- Updated dependencies to target Unity 6 (6000.0+). -## [0.1.0] - 2020-07-30 +### 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` -- Initial submission for package distribution +### Removed +- Legacy tap detection (replaced by Unity Input System's `TapInteraction`). +- Gamepad input management (out of scope for mobile services). diff --git a/Runtime/Plugins.meta b/Plugins.meta similarity index 77% rename from Runtime/Plugins.meta rename to Plugins.meta index 7517683..5df7541 100644 --- a/Runtime/Plugins.meta +++ b/Plugins.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: cb80f701082174eb6851afe7b470be9f +guid: 6393a4195aa9944d5b3feb6e8084e3ca folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Runtime/Plugins/iOS.meta b/Plugins/iOS.meta similarity index 100% rename from Runtime/Plugins/iOS.meta rename to Plugins/iOS.meta diff --git a/Runtime/Plugins/iOS/NativeUi.m b/Plugins/iOS/NativeUi.m similarity index 100% rename from Runtime/Plugins/iOS/NativeUi.m rename to Plugins/iOS/NativeUi.m diff --git a/Runtime/Plugins/iOS/NativeUi.m.meta b/Plugins/iOS/NativeUi.m.meta similarity index 100% rename from Runtime/Plugins/iOS/NativeUi.m.meta rename to Plugins/iOS/NativeUi.m.meta diff --git a/README.md b/README.md index 277163d..9c7aefa 100644 --- a/README.md +++ b/README.md @@ -1,229 +1,94 @@ -# Package Starter Kit - -The purpose of this starter kit is to provide the data structure and development guidelines for new packages meant for the **Unity Package Manager (UPM)**. - -## Are you ready to become a package? -The Package Manager is a work in progress for Unity. Because of that, your package needs to meet these criteria to become an official Unity package: -- **Your code accesses public Unity C# APIs only.** -- **Your code doesn't require security, obfuscation, or conditional access control.** - - -## Package structure - -```none - - ├── package.json - ├── README.md - ├── CHANGELOG.md - ├── Third Party Notices.md - ├── Editor - │ ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.asmdef - │ └── EditorExample.cs - ├── Runtime - │ ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.asmdef - │ └── RuntimeExample.cs - ├── Tests - │ ├── .tests.json - │ ├── Editor - │ │ ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.Tests.asmdef - │ │ └── EditorExampleTest.cs - │ └── Runtime - │ ├── FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Tests.asmdef - │ └── RuntimeExampleTest.cs - ├── Samples - │ └── Example - │ ├── .sample.json - │ └── SampleExample.cs - └── Documentation - ├── Native UI.md - └── Images -``` - -## Develop your package -Package development works best within the Unity Editor. Here's how to get started: - -1. Enter your package name. The name you choose should contain your default organization followed by the name you typed. For example: `FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi`. - -2. [Enter the information](#FillOutFields) for your package in the `package.json` file. - -3. [Rename and update](#Asmdef) assembly definition files. - -4. [Document](#Doc) your package. - -5. [Add samples](#Populate) to your package (code & assets). - -6. [Validate](#Valid) your package. - -7. [Add tests](#Tests) to your package. - -8. Update the `CHANGELOG.md` file. - - Every new feature or bug fix should have a trace in this file. For more details on the chosen changelog format, see [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - -9. Make sure your package [meets all legal requirements](#Legal). - -10. Publish your package. - - - - -### Completing the package manifest - -You can either modify the package manifest (`package.json`) file directly in the Inspector or by using an external editor. - -To use the Inspector, select the `package.json` file in the Project browser. The **Package Native UI Manifest** page opens for editing. - -Update these required attributes in the `package.json` file: - -| **Attribute name:** | **Description:** | -| ------------------- | ------------------------------------------------------------ | -| **name** | The officially registered package name. This name must conform to the [Unity Package Manager naming convention](https://docs.unity3d.com/Manual/upm-manifestPkg.html#name), which uses reverse domain name notation. For example:
`"com.[YourCompanyName].[your-package-name]"` | -| **displayName** | A user-friendly name to appear in the Unity Editor (for example, in the Project Browser, the Package Manager window, etc.). For example:
`"Terrain Builder SDK"`
__NOTE:__ Use a display name that will help users understand what your package is intended for. | -| **version** | The package version number (**'MAJOR.MINOR.PATCH"**). This value must respect [semantic versioning](http://semver.org/). For more information, see [Package version](https://docs.unity3d.com/Manual/upm-manifestPkg.html#pkg-ver) in the Unity User Manual. | -| **unity** | The lowest Unity version the package is compatible with. If omitted, the package is considered compatible with all Unity versions.

The expected format is "**<MAJOR>.<MINOR>**" (for example, **2018.3**). | -| **description** | A brief description of the package. This is the text that appears in the [details view](upm-ui-details) of the Packages window. Any [UTF-8](https://en.wikipedia.org/wiki/UTF-8) character code is supported. This means that you can use special formatting character codes, such as line breaks (**\n**) and bullets (**\u25AA**). | - -Update the following recommended fields in file **package.json**: - -| **Attribute name:** | **Description:** | -| ------------------- | ------------------------------------------------------------ | -| **dependencies** | A map of package dependencies. Keys are package names, and values are specific versions. They indicate other packages that this package depends on. For more information, see [Dependencies](https://docs.unity3d.com/Manual/upm-dependencies.html) in the Unity User Manual.

**NOTE**: The Package Manager does not support range syntax, only **SemVer** versions. | -| **keywords** | An array of keywords used by the Package Manager search APIs. This helps users find relevant packages. | - - - - -### Updating the Assembly Definition files - -You must associate scripts inside a package to an assembly definition file (.asmdef). Assembly definition files are the Unity equivalent to a C# project in the .NET ecosystem. You must set explicit references in the assembly definition file to other assemblies (whether in the same package or in external packages). See [Assembly Definitions](https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html) for more details. - -Use these conventions for naming and storing your assembly definition files to ensure that the compiled assembly filenames follow the [.NET Framework Design Guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/): - -* Store Editor-specific code under a root editor assembly definition file: - - `Editor/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.asmdef` - -* Store runtime-specific code under a root runtime assembly definition file: - - `Runtime/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.asmdef` +# Mobile Services -* Configure related test assemblies for your editor and runtime scripts: +[![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Version](https://img.shields.io/badge/version-1.0.0-green.svg)](CHANGELOG.md) - `Tests/Editor/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Editor.Tests.asmdef` +## Overview - `Tests/Runtime/FirstLightGames9Ad7d6dcE4674628Adec8954Dcbaabe5.NativeUi.Tests.asmdef` +**Mobile Services** is a consolidated package providing essential platform-specific services for Unity mobile projects. It simplifies native integration for UI, notifications, and advanced touch gestures. -To get a more general view of a recommended package folder layout, see [Package layout](https://docs.unity3d.com/Manual/cus-layout.html). +This package consolidates three legacy packages into a single, cohesive foundation: +- **Native UI**: Alerts, toasts, and game review prompts. +- **Notifications**: Local and remote push notification management. +- **Gestures**: Advanced swipe and drag detection (extracted from legacy inputextensions). +--- +## Key Features - -### Providing documentation +- **🎭 Native UI Service** - Call native OS dialogs and toasts without writing platform-specific code. +- **📨 Notification Service** - Schedule, cancel, and manage local/remote notifications with ease. +- **👆 Gesture Controller** - Robust swipe detection with velocity, direction, and consistency metrics. +- **📱 Platform Optimized** - Built specifically for iOS and Android with editor fallbacks. +- **⚡ Async Ready** - Modern C# implementation designed for high performance. -Use the `Documentations~/Native UI.md` documentation file to create preliminary, high-level documentation. This document should introduce users to the features and sample files included in your package. Your package documentation files will be used to generate online and local docs, available from the Package Manager UI. +--- -**Document your public APIs** -* All public APIs need to be documented with **XmlDoc**. -* API documentation is generated from [XmlDoc tags](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/xml-documentation-comments) included with all public APIs found in the package. See [Editor/EditorExample.cs](Editor/EditorExample.cs) for an example. +## Installation +### Via Unity Package Manager (UPM) +1. Open Unity Package Manager (`Window` → `Package Manager`). +2. Click the `+` button and select `Add package from git URL`. +3. Enter the following URL: + ``` + https://github.com/CoderGamester/com.gamelovers.mobileservices.git + ``` +--- - -### Adding Assets to your package +## Quick Start -If your package contains a sample, rename the `Samples/Example` folder, and update the `.sample.json` file in it. +### 1. Native UI +```csharp +using GameLovers.MobileServices.NativeUi; -In the case where your package contains multiple samples, you can make a copy of the `Samples/Example` folder for each sample, and update the `.sample.json` file accordingly. - -Similar to `.tests.json` file, there is a `"createSeparatePackage"` field in `.sample.json`. If set to true, the CI will create a separate package for the sample. - -Delete the `Samples` folder altogether if your package does not need samples. - -As of Unity release 2019.1, the Package Manager recognizes the `/Samples` directory in a package. Unity doesn't automatically import samples when a user adds the package to a Project. However, users can click a button in the details view of a package in the **Packages** window to optionally import samples into their `/Assets` directory. - - - - - -### Validating your package - -Before you publish your package, you need to make sure that it passes all the necessary validation checks by using the Package Validation Suite extension (optional). - -Once you install the Validation Suite package, a **Validate** button appears in the details view of a package in the **Packages** window. To install the extension, follow these steps: - -1. Point your Project manifest to a staging registry by adding this line to the manifest: - `"registry": "https://staging-packages.unity.com"` -2. Install the **Package Validation Suite v0.3.0-preview.13** or above from the **Packages** window in Unity. Make sure the package scope is set to **All Packages**, and select **Show preview packages** from the **Advanced** menu. -3. After installation, a **Validate** button appears in the **Packages** window. Click the button to run a series of tests, then click the **See Results** button for additional information: - * If it succeeds, a green bar with a **Success** message appears. - * If it fails, a red bar with a **Failed** message appears. - -**NOTE:** The validation suite is still in preview. - - - - - -### Adding tests to your package - -All packages must contain tests. Tests are essential for Unity to ensure that the package works as expected in different scenarios. - -**Editor tests** -* Write all your Editor Tests in `Tests/Editor` - -**Playmode Tests** - -* Write all your Playmode Tests in `Tests/Runtime`. - -#### Separating the tests from the package - -You can create a separate package for the tests, which allows you to exclude a large number of tests and Assets from being published in your main package, while still making it easy to test it. - -Open the `Tests/.tests.json` file and set the **createSeparatePackage** attribute: - -| **Value to set:** | **Result:** | -| ----------------- | ------------------------------------------------------------ | -| **true** | CI creates a separate package for these tests. At publish time, the Package Manager adds metadata to link the packages together. | -| **false** | Keep the tests as part of the published package. | - - - - -### Meeting the legal requirements - -You can use the Third Party Notices.md file to make sure your package meets any legal requirements. For example, here is a sample license file from the Unity Timeline package: +// Show a simple alert +NativeUiService.ShowAlertPopUp(false, "Welcome", "Thank you for playing!", + new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); +// Show a toast message (Android only) +NativeUiService.ShowToastMessage("Item Collected", false); ``` -Unity Timeline copyright © 2017-2019 Unity Technologies ApS -Licensed under the Unity Companion License for Unity-dependent projects--see [Unity Companion License](http://www.unity3d.com/legal/licenses/Unity_Companion_License). +### 2. Notifications +```csharp +using GameLovers.MobileServices.Notifications; -Unless expressly provided otherwise, the Software under this license is made available strictly on an “AS IS” BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions. +// Initialize service with channels +var service = new MobileNotificationService(new GameNotificationChannel("default", "Default", "Default Channel")); +// Schedule a notification +var notification = service.CreateNotification(); +notification.Title = "Daily Reward"; +notification.Body = "Your reward is ready!"; +notification.DeliveryTime = DateTime.Now.AddHours(24); +service.ScheduleNotification(notification); ``` +### 3. Swipe Gestures +```csharp +using GameLovers.MobileServices.Gestures; - -#### Third Party Notices - -If your package has third-party elements, you can include the licenses in a Third Party Notices.md file. You can include a **Component Name**, **License Type**, and **Provide License Details** section for each license you want to include. For example: - +// Attach GestureController to a GameObject and listen to events +gestureController.Swiped += (swipe) => { + Debug.Log($"Swiped {swipe.SwipeDirection} with velocity {swipe.SwipeVelocity}"); +}; ``` -This package contains third-party software components governed by the license(s) indicated below: -Component Name: Semver +--- -License Type: "MIT" +## Migration from Legacy Packages -[SemVer License](https://github.com/myusername/semver/blob/master/License.txt) +If you are migrating from the old separate packages, please refer to [MIGRATION.md](MIGRATION.md) for detailed namespace and API mapping changes. -Component Name: MyComponent +--- -License Type: "MyLicense" +## License -[MyComponent License](https://www.mycompany.com/licenses/License.txt) +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. -``` +--- -**NOTE**: Any URLs you use should point to a location that contains the reproduced license and the copyright information (if applicable). +**Made with ❤️ for the Unity community** diff --git a/Runtime/GameLovers.MobileServices.asmdef b/Runtime/GameLovers.MobileServices.asmdef new file mode 100644 index 0000000..995e973 --- /dev/null +++ b/Runtime/GameLovers.MobileServices.asmdef @@ -0,0 +1,18 @@ +{ + "name": "GameLovers.MobileServices", + "rootNamespace": "GameLovers.MobileServices", + "references": [ + "Unity.Notifications", + "Unity.Notifications.Android", + "Unity.Notifications.iOS", + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [] +} diff --git a/Runtime/GameLovers.NativeUi.asmdef.meta b/Runtime/GameLovers.MobileServices.asmdef.meta similarity index 76% rename from Runtime/GameLovers.NativeUi.asmdef.meta rename to Runtime/GameLovers.MobileServices.asmdef.meta index 077a9cb..03869c0 100644 --- a/Runtime/GameLovers.NativeUi.asmdef.meta +++ b/Runtime/GameLovers.MobileServices.asmdef.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 113cff88732a84c2680c9dbbdbefc792 +guid: cbe822915a7aa48eba069762603eb81d AssemblyDefinitionImporter: externalObjects: {} userData: diff --git a/Runtime/GameLovers.NativeUi.asmdef b/Runtime/GameLovers.NativeUi.asmdef deleted file mode 100644 index 2902d16..0000000 --- a/Runtime/GameLovers.NativeUi.asmdef +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "GameLovers.NativeUi", - "references": [], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} \ No newline at end of file diff --git a/Runtime/Gestures.meta b/Runtime/Gestures.meta new file mode 100644 index 0000000..cc9dc80 --- /dev/null +++ b/Runtime/Gestures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f1c420d688824f7eb6729b2f1a4d316 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Gestures/ActiveGesture.cs b/Runtime/Gestures/ActiveGesture.cs new file mode 100755 index 0000000..59c5ef9 --- /dev/null +++ b/Runtime/Gestures/ActiveGesture.cs @@ -0,0 +1,118 @@ +using UnityEngine; + +namespace GameLovers.MobileServices.Gestures +{ + /// + /// An in-progress potential gesture for given input. + /// + internal sealed class ActiveGesture + { + /// + /// Input ID that generated this gesture. + /// + public int InputId; + + /// + /// The time this potential gesture started. + /// + public readonly double StartTime; + + /// + /// The time this potential gesture ended. + /// + public double EndTime; + + /// + /// The position this gesture started at. + /// + public readonly Vector2 StartPosition; + + /// + /// The position this gesture was at during the last sample. + /// + public Vector2 PreviousPosition; + + /// + /// The position this gesture ended at. + /// + public Vector2 EndPosition; + + /// + /// How many samples we had for this gesture. + /// + public int Samples; + + /// + /// How consistent the swipe was in its direction. Approaches 1 for straight lines. + /// + /// + /// This is calculated as the average of the dot products of every line segment (normalized) against a normalized + /// vector to the tip of the swipe from the start. + /// + public float SwipeDirectionSameness; + + /// + /// The total travel distance this gesture's made in screen units. This will always be AT LEAST the distance + /// between and , but will likely be longer for any + /// non straight line gestures. + /// + public float TravelDistance; + + /// + /// Accumulated sum of all normalized movement vectors. + /// + private Vector2 accumulatedNormalized; + + /// + /// Instantiate a new potential gesture. + /// + /// The input id for this gesture. + /// The gesture's start position. + /// The time the gesture has started. + public ActiveGesture(int inputId, Vector2 startPosition, double startTime) + { + InputId = inputId; + EndTime = StartTime = startTime; + EndPosition = StartPosition = startPosition; + Samples = 1; + SwipeDirectionSameness = 1; + accumulatedNormalized = Vector2.zero; + } + + /// + /// Submit a new position to this gesture. + /// + /// The position of the new sample. + /// The time of the new sample. + public void SubmitPoint(Vector2 position, double time) + { + Vector2 toNewPosition = position - EndPosition; + float distanceMoved = toNewPosition.magnitude; + + // Set new end time + EndTime = time; + + if (Mathf.Approximately(distanceMoved, 0)) + { + // Skipping point that is in the same position as the last one + return; + } + + // Normalize + toNewPosition /= distanceMoved; + + Samples++; + Vector2 toNewEndPosition = (position - StartPosition).normalized; + + // Set new end position and previous positions + PreviousPosition = EndPosition; + EndPosition = position; + + accumulatedNormalized += toNewPosition; + + SwipeDirectionSameness = Vector2.Dot(toNewEndPosition, accumulatedNormalized / (Samples - 1)); + + TravelDistance += distanceMoved; + } + } +} diff --git a/Runtime/Gestures/ActiveGesture.cs.meta b/Runtime/Gestures/ActiveGesture.cs.meta new file mode 100644 index 0000000..11627b6 --- /dev/null +++ b/Runtime/Gestures/ActiveGesture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1394de70e523f463088df8e2da31f934 \ No newline at end of file diff --git a/Runtime/Gestures/Controls.meta b/Runtime/Gestures/Controls.meta new file mode 100644 index 0000000..ce3ca77 --- /dev/null +++ b/Runtime/Gestures/Controls.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 17a4bb9d298724559b013147e0bb9d14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Gestures/Controls/PointerControls.cs b/Runtime/Gestures/Controls/PointerControls.cs new file mode 100755 index 0000000..60163d9 --- /dev/null +++ b/Runtime/Gestures/Controls/PointerControls.cs @@ -0,0 +1,603 @@ +// GENERATED AUTOMATICALLY FROM 'Packages/com.gamelovers.inputextension/Runtime/Controls/PointerControls.inputactions' + +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Utilities; + +namespace GameLovers.MobileServices.Gestures.Controls +{ + public class @PointerControls : IInputActionCollection, IDisposable + { + public InputActionAsset asset { get; } + public @PointerControls() + { + asset = InputActionAsset.FromJson(@"{ + ""name"": ""PointerControls"", + ""maps"": [ + { + ""name"": ""pointer"", + ""id"": ""3c570214-6b14-44a9-8e61-3e4dc9ac469f"", + ""actions"": [ + { + ""name"": ""point"", + ""type"": ""Value"", + ""id"": ""4d610105-c5af-439c-8a02-4f1976d8da67"", + ""expectedControlType"": """", + ""processors"": """", + ""interactions"": """" + } + ], + ""bindings"": [ + { + ""name"": ""MouseAndPen"", + ""id"": ""6503119b-11d7-4b61-9465-8ab83699a36c"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""33cce31d-cbc6-4899-8781-9ab727534e60"", + ""path"": ""/leftButton"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Mouse"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""contact"", + ""id"": ""418f64e8-359e-4b05-8869-c8a1165e44d9"", + ""path"": ""/tip"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Pen"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""60ae03ce-5f16-4763-9102-400558002a23"", + ""path"": ""/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Mouse"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""4d35537c-6a23-4f4a-bad4-eaeed0a67248"", + ""path"": ""/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Pen"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""tilt"", + ""id"": ""e14524a1-8951-4672-98aa-49fda32a7548"", + ""path"": ""/tilt"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Pen"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""ce154ce8-174d-4bbf-adb7-4f8634b86a24"", + ""path"": ""/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Pen"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""twist"", + ""id"": ""8f871c6e-49c6-4e0b-9d0f-65263a53ac84"", + ""path"": ""/twist"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Pen"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""Touch0"", + ""id"": ""f5819de4-b7e5-4745-9d93-2b45e9dad897"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""a0baebac-8b22-4db8-9cf9-7ba8c4d8aab0"", + ""path"": ""/touch0/press"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""79b4615b-9534-4aa7-af27-90a442add4bc"", + ""path"": ""/touch0/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""radius"", + ""id"": ""07bde460-f80a-45ae-823d-db51f6bda4bb"", + ""path"": ""/touch0/radius"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""b72c4336-bd65-457f-bacd-cf9933eb2fc7"", + ""path"": ""/touch0/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""inputId"", + ""id"": ""6be63793-ef04-469d-ac12-779245b71ba9"", + ""path"": ""/touch0/touchId"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""Touch1"", + ""id"": ""448ef65c-b779-4014-bdc4-1cf793b11223"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""a7e9f275-8e72-4221-b947-e1d972629254"", + ""path"": ""/touch1/press"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""64552938-2fd4-4fd9-aab5-c6873527f9fa"", + ""path"": ""/touch1/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""radius"", + ""id"": ""ccf25457-9cab-4137-bfb6-d9a3f07cdf02"", + ""path"": ""/touch1/radius"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""1cd9d53e-4d19-4f1d-a4cf-68df808d026a"", + ""path"": ""/touch1/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""inputId"", + ""id"": ""583bf8d0-0d65-4042-9496-64d4f3b7b7e2"", + ""path"": ""/touch1/touchId"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""Touch2"", + ""id"": ""19c33421-5a1b-427d-a668-3c9e57862d40"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""70013879-1601-4e2d-959f-ac99c1e74af5"", + ""path"": ""/touch2/press"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""392b2a12-03e3-42b4-9c70-89e916237cd0"", + ""path"": ""/touch2/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""radius"", + ""id"": ""301637a3-d0e1-4181-a288-53c2f7057337"", + ""path"": ""/touch2/radius"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""28c1c15f-64bb-4129-89a3-67814ae5f3a6"", + ""path"": ""/touch2/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""inputId"", + ""id"": ""8b632dbd-6147-4928-8c90-2d2f64667fcb"", + ""path"": ""/touch2/touchId"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""Touch3"", + ""id"": ""05259a7b-4240-446d-acb5-e4a580f53994"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""3e7de849-5b93-4126-84a4-2d03d20ef368"", + ""path"": ""/touch3/press"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""120da9ee-f4b2-49a7-8a29-d4e1f94f7031"", + ""path"": ""/touch3/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""radius"", + ""id"": ""29d3a1b9-e12a-470a-8ea1-9993d5b53e87"", + ""path"": ""/touch3/radius"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""7209bbd8-72a4-440a-911a-f2f271b586a8"", + ""path"": ""/touch3/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""inputId"", + ""id"": ""7f7a6001-89d2-4947-86b9-00d9d24dadec"", + ""path"": ""/touch3/touchId"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""Touch4"", + ""id"": ""1b233d85-1ef7-4af4-ac02-9615393f52c4"", + ""path"": ""PointerInput"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""point"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""contact"", + ""id"": ""53bdf954-414a-499c-bc07-8f4f09f3ad58"", + ""path"": ""/touch4/press"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""position"", + ""id"": ""a39e1415-77d5-4303-be04-1046a4923ced"", + ""path"": ""/touch4/position"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""radius"", + ""id"": ""13406675-5622-4525-8fbc-528295a19a0e"", + ""path"": ""/touch4/radius"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""pressure"", + ""id"": ""23c29f3f-f6ec-474f-b6af-ceb385bac764"", + ""path"": ""/touch4/pressure"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""inputId"", + ""id"": ""3aafb622-08a2-4a5f-ba33-2def589b35a2"", + ""path"": ""/touch4/touchId"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Touch"", + ""action"": ""point"", + ""isComposite"": false, + ""isPartOfComposite"": true + } + ] + } + ], + ""controlSchemes"": [ + { + ""name"": ""Mouse"", + ""bindingGroup"": ""Mouse"", + ""devices"": [ + { + ""devicePath"": """", + ""isOptional"": false, + ""isOR"": false + } + ] + }, + { + ""name"": ""Pen"", + ""bindingGroup"": ""Pen"", + ""devices"": [ + { + ""devicePath"": """", + ""isOptional"": false, + ""isOR"": false + } + ] + }, + { + ""name"": ""Touch"", + ""bindingGroup"": ""Touch"", + ""devices"": [ + { + ""devicePath"": """", + ""isOptional"": false, + ""isOR"": false + } + ] + } + ] +}"); + // pointer + m_pointer = asset.FindActionMap("pointer", throwIfNotFound: true); + m_pointer_point = m_pointer.FindAction("point", throwIfNotFound: true); + } + + public void Dispose() + { + UnityEngine.Object.Destroy(asset); + } + + public InputBinding? bindingMask + { + get => asset.bindingMask; + set => asset.bindingMask = value; + } + + public ReadOnlyArray? devices + { + get => asset.devices; + set => asset.devices = value; + } + + public ReadOnlyArray controlSchemes => asset.controlSchemes; + + public bool Contains(InputAction action) + { + return asset.Contains(action); + } + + public IEnumerator GetEnumerator() + { + return asset.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Enable() + { + asset.Enable(); + } + + public void Disable() + { + asset.Disable(); + } + + // pointer + private readonly InputActionMap m_pointer; + private IPointerActions m_PointerActionsCallbackInterface; + private readonly InputAction m_pointer_point; + public struct PointerActions + { + private @PointerControls m_Wrapper; + public PointerActions(@PointerControls wrapper) { m_Wrapper = wrapper; } + public InputAction @point => m_Wrapper.m_pointer_point; + public InputActionMap Get() { return m_Wrapper.m_pointer; } + public void Enable() { Get().Enable(); } + public void Disable() { Get().Disable(); } + public bool enabled => Get().enabled; + public static implicit operator InputActionMap(PointerActions set) { return set.Get(); } + public void SetCallbacks(IPointerActions instance) + { + if (m_Wrapper.m_PointerActionsCallbackInterface != null) + { + @point.started -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; + @point.performed -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; + @point.canceled -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; + } + m_Wrapper.m_PointerActionsCallbackInterface = instance; + if (instance != null) + { + @point.started += instance.OnPoint; + @point.performed += instance.OnPoint; + @point.canceled += instance.OnPoint; + } + } + } + public PointerActions @pointer => new PointerActions(this); + private int m_MouseSchemeIndex = -1; + public InputControlScheme MouseScheme + { + get + { + if (m_MouseSchemeIndex == -1) m_MouseSchemeIndex = asset.FindControlSchemeIndex("Mouse"); + return asset.controlSchemes[m_MouseSchemeIndex]; + } + } + private int m_PenSchemeIndex = -1; + public InputControlScheme PenScheme + { + get + { + if (m_PenSchemeIndex == -1) m_PenSchemeIndex = asset.FindControlSchemeIndex("Pen"); + return asset.controlSchemes[m_PenSchemeIndex]; + } + } + private int m_TouchSchemeIndex = -1; + public InputControlScheme TouchScheme + { + get + { + if (m_TouchSchemeIndex == -1) m_TouchSchemeIndex = asset.FindControlSchemeIndex("Touch"); + return asset.controlSchemes[m_TouchSchemeIndex]; + } + } + public interface IPointerActions + { + void OnPoint(InputAction.CallbackContext context); + } + } +} diff --git a/Runtime/Gestures/Controls/PointerControls.cs.meta b/Runtime/Gestures/Controls/PointerControls.cs.meta new file mode 100644 index 0000000..bc4fb51 --- /dev/null +++ b/Runtime/Gestures/Controls/PointerControls.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18cb32298728641fd9449640aecd7101 \ No newline at end of file diff --git a/Runtime/Gestures/Controls/PointerControls.inputactions b/Runtime/Gestures/Controls/PointerControls.inputactions new file mode 100755 index 0000000..193256f --- /dev/null +++ b/Runtime/Gestures/Controls/PointerControls.inputactions @@ -0,0 +1,477 @@ +{ + "name": "PointerControls", + "maps": [ + { + "name": "pointer", + "id": "3c570214-6b14-44a9-8e61-3e4dc9ac469f", + "actions": [ + { + "name": "point", + "type": "Value", + "id": "4d610105-c5af-439c-8a02-4f1976d8da67", + "expectedControlType": "", + "processors": "", + "interactions": "" + } + ], + "bindings": [ + { + "name": "MouseAndPen", + "id": "6503119b-11d7-4b61-9465-8ab83699a36c", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "33cce31d-cbc6-4899-8781-9ab727534e60", + "path": "/leftButton", + "interactions": "", + "processors": "", + "groups": ";Mouse", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "contact", + "id": "418f64e8-359e-4b05-8869-c8a1165e44d9", + "path": "/tip", + "interactions": "", + "processors": "", + "groups": ";Pen", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "60ae03ce-5f16-4763-9102-400558002a23", + "path": "/position", + "interactions": "", + "processors": "", + "groups": ";Mouse", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "4d35537c-6a23-4f4a-bad4-eaeed0a67248", + "path": "/position", + "interactions": "", + "processors": "", + "groups": ";Pen", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "tilt", + "id": "e14524a1-8951-4672-98aa-49fda32a7548", + "path": "/tilt", + "interactions": "", + "processors": "", + "groups": ";Pen", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "ce154ce8-174d-4bbf-adb7-4f8634b86a24", + "path": "/pressure", + "interactions": "", + "processors": "", + "groups": ";Pen", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "twist", + "id": "8f871c6e-49c6-4e0b-9d0f-65263a53ac84", + "path": "/twist", + "interactions": "", + "processors": "", + "groups": ";Pen", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Touch0", + "id": "f5819de4-b7e5-4745-9d93-2b45e9dad897", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "a0baebac-8b22-4db8-9cf9-7ba8c4d8aab0", + "path": "/touch0/press", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "79b4615b-9534-4aa7-af27-90a442add4bc", + "path": "/touch0/position", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "radius", + "id": "07bde460-f80a-45ae-823d-db51f6bda4bb", + "path": "/touch0/radius", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "b72c4336-bd65-457f-bacd-cf9933eb2fc7", + "path": "/touch0/pressure", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "inputId", + "id": "6be63793-ef04-469d-ac12-779245b71ba9", + "path": "/touch0/touchId", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Touch1", + "id": "448ef65c-b779-4014-bdc4-1cf793b11223", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "a7e9f275-8e72-4221-b947-e1d972629254", + "path": "/touch1/press", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "64552938-2fd4-4fd9-aab5-c6873527f9fa", + "path": "/touch1/position", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "radius", + "id": "ccf25457-9cab-4137-bfb6-d9a3f07cdf02", + "path": "/touch1/radius", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "1cd9d53e-4d19-4f1d-a4cf-68df808d026a", + "path": "/touch1/pressure", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "inputId", + "id": "583bf8d0-0d65-4042-9496-64d4f3b7b7e2", + "path": "/touch1/touchId", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Touch2", + "id": "19c33421-5a1b-427d-a668-3c9e57862d40", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "70013879-1601-4e2d-959f-ac99c1e74af5", + "path": "/touch2/press", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "392b2a12-03e3-42b4-9c70-89e916237cd0", + "path": "/touch2/position", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "radius", + "id": "301637a3-d0e1-4181-a288-53c2f7057337", + "path": "/touch2/radius", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "28c1c15f-64bb-4129-89a3-67814ae5f3a6", + "path": "/touch2/pressure", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "inputId", + "id": "8b632dbd-6147-4928-8c90-2d2f64667fcb", + "path": "/touch2/touchId", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Touch3", + "id": "05259a7b-4240-446d-acb5-e4a580f53994", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "3e7de849-5b93-4126-84a4-2d03d20ef368", + "path": "/touch3/press", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "120da9ee-f4b2-49a7-8a29-d4e1f94f7031", + "path": "/touch3/position", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "radius", + "id": "29d3a1b9-e12a-470a-8ea1-9993d5b53e87", + "path": "/touch3/radius", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "7209bbd8-72a4-440a-911a-f2f271b586a8", + "path": "/touch3/pressure", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "inputId", + "id": "7f7a6001-89d2-4947-86b9-00d9d24dadec", + "path": "/touch3/touchId", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Touch4", + "id": "1b233d85-1ef7-4af4-ac02-9615393f52c4", + "path": "PointerInput", + "interactions": "", + "processors": "", + "groups": "", + "action": "point", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "contact", + "id": "53bdf954-414a-499c-bc07-8f4f09f3ad58", + "path": "/touch4/press", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "position", + "id": "a39e1415-77d5-4303-be04-1046a4923ced", + "path": "/touch4/position", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "radius", + "id": "13406675-5622-4525-8fbc-528295a19a0e", + "path": "/touch4/radius", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "pressure", + "id": "23c29f3f-f6ec-474f-b6af-ceb385bac764", + "path": "/touch4/pressure", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "inputId", + "id": "3aafb622-08a2-4a5f-ba33-2def589b35a2", + "path": "/touch4/touchId", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "point", + "isComposite": false, + "isPartOfComposite": true + } + ] + } + ], + "controlSchemes": [ + { + "name": "Mouse", + "basedOn": "", + "bindingGroup": "Mouse", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Pen", + "basedOn": "", + "bindingGroup": "Pen", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Touch", + "basedOn": "", + "bindingGroup": "Touch", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + } + ] +} \ No newline at end of file diff --git a/Runtime/Gestures/Controls/PointerControls.inputactions.meta b/Runtime/Gestures/Controls/PointerControls.inputactions.meta new file mode 100644 index 0000000..269851c --- /dev/null +++ b/Runtime/Gestures/Controls/PointerControls.inputactions.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 3cb71a3684e884cb6976650d8c1b1063 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3} + generateWrapperCode: 0 + wrapperCodePath: + wrapperClassName: + wrapperCodeNamespace: diff --git a/Runtime/Gestures/GestureController.cs b/Runtime/Gestures/GestureController.cs new file mode 100755 index 0000000..af87af5 --- /dev/null +++ b/Runtime/Gestures/GestureController.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.UI; + +namespace GameLovers.MobileServices.Gestures +{ + /// + /// Controller that interprets takes pointer input from and detects + /// directional swipes and detects taps. + /// + public class GestureController : MonoBehaviour + { + [SerializeField] + private PointerInputManager inputManager; + + // Maximum duration of a press before it can no longer be considered a tap. + [SerializeField] + private float maxTapDuration = 0.2f; + + // Maximum distance in screen units that a tap can drift from its original position before + // it is no longer considered a tap. + [SerializeField] + private float maxTapDrift = 5.0f; + + // Maximum duration of a swipe before it is no longer considered to be a valid swipe. + [SerializeField] + private float maxSwipeDuration = 0.5f; + + // Minimum distance in screen units that a swipe must move before it is considered a swipe. + // Note that if this is smaller or equal to maxTapDrift, then it is possible for a user action to be + // returned as both a swipe and a tap. + [SerializeField] + private float minSwipeDistance = 10.0f; + + // How much a swipe should consistently be in the same direction before it is considered a swipe. + [SerializeField] + private float swipeDirectionSamenessThreshold = 0.6f; + + [Header("Debug"), SerializeField] + private Text label; + + // Mapping of input IDs to their active gesture tracking objects. + private readonly Dictionary activeGestures = new Dictionary(); + + /// + /// Event fired when the user presses on the screen. + /// + public new event Action Pressed; + + /// + /// Event fired for every motion (possibly multiple times a frame) of a potential swipe gesture. + /// + public event Action PotentiallySwiped; + + /// + /// Event fired when a user performs a swipe gesture. + /// + public event Action Swiped; + + /// + /// Event fired when a user performs a tap gesture, on releasing. + /// + public event Action Tapped; + + protected virtual void Awake() + { + inputManager.Pressed += OnPressed; + inputManager.Dragged += OnDragged; + inputManager.Released += OnReleased; + } + + /// + /// Checks whether a given active gesture will be a valid swipe. + /// + private bool IsValidSwipe(ref ActiveGesture gesture) + { + return gesture.TravelDistance >= minSwipeDistance && + (gesture.StartTime - gesture.EndTime) <= maxSwipeDuration && + gesture.SwipeDirectionSameness >= swipeDirectionSamenessThreshold; + } + + /// + /// Checks whether a given active gesture will be a valid tap. + /// + private bool IsValidTap(ref ActiveGesture gesture) + { + return gesture.TravelDistance <= maxTapDrift && + (gesture.StartTime - gesture.EndTime) <= maxTapDuration; + } + + private void OnPressed(PointerInput input, double time) + { + Debug.Assert(!activeGestures.ContainsKey(input.InputId)); + + var newGesture = new ActiveGesture(input.InputId, input.Position, time); + activeGestures.Add(input.InputId, newGesture); + + DebugInfo(newGesture); + + Pressed?.Invoke(new SwipeInput(newGesture)); + } + + private void OnDragged(PointerInput input, double time) + { + if (!activeGestures.TryGetValue(input.InputId, out var existingGesture)) + { + // Probably caught by UI, or the input was otherwise lost + return; + } + + existingGesture.SubmitPoint(input.Position, time); + + if (IsValidSwipe(ref existingGesture)) + { + PotentiallySwiped?.Invoke(new SwipeInput(existingGesture)); + } + + DebugInfo(existingGesture); + } + + private void OnReleased(PointerInput input, double time) + { + if (!activeGestures.TryGetValue(input.InputId, out var existingGesture)) + { + // Probably caught by UI, or the input was otherwise lost + return; + } + + activeGestures.Remove(input.InputId); + existingGesture.SubmitPoint(input.Position, time); + + if (IsValidSwipe(ref existingGesture)) + { + Swiped?.Invoke(new SwipeInput(existingGesture)); + } + + if (IsValidTap(ref existingGesture)) + { + Tapped?.Invoke(new TapInput(existingGesture)); + } + + DebugInfo(existingGesture); + } + + private void DebugInfo(ActiveGesture gesture) + { + if (label == null) return; + + var builder = new StringBuilder(); + + builder.AppendFormat("ID: {0}", gesture.InputId); + builder.AppendLine(); + builder.AppendFormat("Start Position: {0}", gesture.StartPosition); + builder.AppendLine(); + builder.AppendFormat("Position: {0}", gesture.EndPosition); + builder.AppendLine(); + builder.AppendFormat("Duration: {0}", gesture.EndTime - gesture.StartTime); + builder.AppendLine(); + builder.AppendFormat("Sameness: {0}", gesture.SwipeDirectionSameness); + builder.AppendLine(); + builder.AppendFormat("Travel distance: {0}", gesture.TravelDistance); + builder.AppendLine(); + builder.AppendFormat("Samples: {0}", gesture.Samples); + builder.AppendLine(); + builder.AppendFormat("Realtime since startup: {0}", Time.realtimeSinceStartup); + builder.AppendLine(); + builder.AppendFormat("Starting Timestamp: {0}", gesture.StartTime); + builder.AppendLine(); + builder.AppendFormat("Ending Timestamp: {0}", gesture.EndTime); + builder.AppendLine(); + + label.text = builder.ToString(); + + var worldStart = Camera.main.ScreenToWorldPoint(gesture.StartPosition); + var worldEnd = Camera.main.ScreenToWorldPoint(gesture.EndPosition); + + worldStart.z += 5; + worldEnd.z += 5; + } + } +} diff --git a/Runtime/Gestures/GestureController.cs.meta b/Runtime/Gestures/GestureController.cs.meta new file mode 100644 index 0000000..8ff6f36 --- /dev/null +++ b/Runtime/Gestures/GestureController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a52942e227ea44719cc286762db5d23 \ No newline at end of file diff --git a/Runtime/Gestures/PointerInput.cs b/Runtime/Gestures/PointerInput.cs new file mode 100755 index 0000000..f4cd220 --- /dev/null +++ b/Runtime/Gestures/PointerInput.cs @@ -0,0 +1,115 @@ +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Layouts; +using UnityEngine.InputSystem.Utilities; + +namespace GameLovers.MobileServices.Gestures +{ + /// + /// Simple object to contain information for drag inputs. + /// + public struct PointerInput + { + public bool Contact; + + /// + /// ID of input type. + /// + public int InputId; + + /// + /// Position of draw input. + /// + public Vector2 Position; + + /// + /// Orientation of draw input pen. + /// + public Vector2? Tilt; + + /// + /// Pressure of draw input. + /// + public float? Pressure; + + /// + /// Radius of draw input. + /// + public Vector2? Radius; + + /// + /// Twist of draw input. + /// + public float? Twist; + } + + // What we do in PointerInputManager is to simply create a separate action for each input we need for PointerInput. + // This here shows a possible alternative that sources all inputs as a single value using a composite. Has pros + // and cons. Biggest pro is that all the controls actuate together and deliver one input value. + // + // NOTE: In PointerControls, we are binding mouse and pen separately from touch. If we didn't care about multitouch, + // we wouldn't have to to that but could rather just bind `/position` etc. However, to source each touch + // as its own separate PointerInput source, we need to have multiple PointerInputComposites. + #if UNITY_EDITOR + [UnityEditor.InitializeOnLoad] + #endif + public class PointerInputComposite : InputBindingComposite + { + [InputControl(layout = "Button")] + public int contact; + + [InputControl(layout = "Vector2")] + public int position; + + [InputControl(layout = "Vector2")] + public int tilt; + + [InputControl(layout = "Vector2")] + public int radius; + + [InputControl(layout = "Axis")] + public int pressure; + + [InputControl(layout = "Axis")] + public int twist; + + [InputControl(layout = "Integer")] + public int inputId; + + public override PointerInput ReadValue(ref InputBindingCompositeContext context) + { + var contact = context.ReadValueAsButton(this.contact); + var pointerId = context.ReadValue(inputId); + var pressure = context.ReadValue(this.pressure); + var radius = context.ReadValue(this.radius); + var tilt = context.ReadValue(this.tilt); + var position = context.ReadValue(this.position); + var twist = context.ReadValue(this.twist); + + return new PointerInput + { + Contact = contact, + InputId = pointerId, + Position = position, + Tilt = tilt != default ? tilt : (Vector2?)null, + Pressure = pressure > 0 ? pressure : (float?)null, + Radius = radius.sqrMagnitude > 0 ? radius : (Vector2?)null, + Twist = twist > 0 ? twist : (float?)null, + }; + } + + #if UNITY_EDITOR + static PointerInputComposite() + { + Register(); + } + + #endif + + [RuntimeInitializeOnLoadMethod] + private static void Register() + { + InputSystem.RegisterBindingComposite(); + } + } +} diff --git a/Runtime/Gestures/PointerInput.cs.meta b/Runtime/Gestures/PointerInput.cs.meta new file mode 100644 index 0000000..fc0fa0f --- /dev/null +++ b/Runtime/Gestures/PointerInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26a1a49bccb1642d8b9241121d9483a4 \ No newline at end of file diff --git a/Runtime/Gestures/PointerInputManager.cs b/Runtime/Gestures/PointerInputManager.cs new file mode 100755 index 0000000..73d5bdf --- /dev/null +++ b/Runtime/Gestures/PointerInputManager.cs @@ -0,0 +1,126 @@ +using System; +using GameLovers.MobileServices.Gestures.Controls; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; + +namespace GameLovers.MobileServices.Gestures +{ + /// + /// Input manager that interprets pen, mouse and touch input for mostly drag related controls. + /// Passes pressure, tilt, twist and touch radius through to drawing components for processing. + /// + /// + /// Couple notes about the control setup: + /// + /// - Touch is split off from mouse and pen instead of just using `<Pointer>/position` etc. + /// in order to support multi-touch. If we just bind to and + /// such, we will correctly receive the primary touch but the primary touch only. So we put + /// bindings for pen and mouse separate to those from touch. + /// - Mouse and pen are put into one composite. The expectation here is that they are not used + /// independently from another and thus don't need to be represented as separate pointer sources. + /// However, we could just as well have one for mice and + /// one for pens. + /// - is enabled on . + /// The reason is that we want to source arbitrary many pointer inputs through one single actions. + /// Without pass-through, the default conflict resolution on actions would kick in and let only + /// one of the composite bindings through at a time. + /// + public class PointerInputManager : MonoBehaviour + { + /// + /// Event fired when the user presses on the screen. + /// + public event Action Pressed; + + /// + /// Event fired as the user drags along the screen. + /// + public event Action Dragged; + + /// + /// Event fired when the user releases a press. + /// + public event Action Released; + + private bool m_Dragging; + private PointerControls m_Controls; + + // These are useful for debugging, especially when touch simulation is on. + [SerializeField] private bool m_UseMouse; + [SerializeField] private bool m_UsePen; + [SerializeField] private bool m_UseTouch; + + protected virtual void Awake() + { + m_Controls = new PointerControls(); + + m_Controls.pointer.point.performed += OnAction; + // The action isn't likely to actually cancel as we've bound it to all kinds of inputs but we still + // hook this up so in case the entire thing resets, we do get a call. + m_Controls.pointer.point.canceled += OnAction; + + SyncBindingMask(); + } + + protected virtual void OnEnable() + { + m_Controls?.Enable(); + } + + protected virtual void OnDisable() + { + m_Controls?.Disable(); + } + + protected void OnAction(InputAction.CallbackContext context) + { + var control = context.control; + var device = control.device; + + var isMouseInput = device is Mouse; + var isPenInput = !isMouseInput && device is Pen; + + // Read our current pointer values. + var drag = context.ReadValue(); + if (isMouseInput) + drag.InputId = PointerInputModule.kMouseLeftId; + else if (isPenInput) + drag.InputId = int.MinValue; + + if (drag.Contact && !m_Dragging) + { + Pressed?.Invoke(drag, context.time); + m_Dragging = true; + } + else if (drag.Contact && m_Dragging) + { + Dragged?.Invoke(drag, context.time); + } + else + { + Released?.Invoke(drag, context.time); + m_Dragging = false; + } + } + + private void SyncBindingMask() + { + if (m_Controls == null) + return; + + if (m_UseMouse && m_UsePen && m_UseTouch) + { + m_Controls.bindingMask = null; + return; + } + + m_Controls.bindingMask = InputBinding.MaskByGroups(m_UseMouse ? "Mouse" : null, m_UsePen ? "Pen" : null, m_UseTouch ? "Touch" : null); + } + + private void OnValidate() + { + SyncBindingMask(); + } + } +} diff --git a/Runtime/Gestures/PointerInputManager.cs.meta b/Runtime/Gestures/PointerInputManager.cs.meta new file mode 100644 index 0000000..ac19950 --- /dev/null +++ b/Runtime/Gestures/PointerInputManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a6f39d5018c24d69817d0c018814a9d \ No newline at end of file diff --git a/Runtime/Gestures/SwipeInput.cs b/Runtime/Gestures/SwipeInput.cs new file mode 100755 index 0000000..ba7a056 --- /dev/null +++ b/Runtime/Gestures/SwipeInput.cs @@ -0,0 +1,77 @@ +using UnityEngine; + +namespace GameLovers.MobileServices.Gestures +{ + /// + /// Simple object to contain information for a swipe input. + /// + public struct SwipeInput + { + /// + /// ID of input that performed this swipe. + /// + public readonly int InputId; + + /// + /// Position that the swipe began. + /// + public readonly Vector2 StartPosition; + + /// + /// Last position that this swipe was at. + /// + public readonly Vector2 PreviousPosition; + + /// + /// End position of the swipe. + /// + public readonly Vector2 EndPosition; + + /// + /// Average normalized direction of the swipe. This is equivalent to + /// (EndPosition - StartPosition).normalized. + /// + public readonly Vector2 SwipeDirection; + + /// + /// Average velocity of the swipe in screen units per second. + /// + public readonly float SwipeVelocity; + + /// + /// How much the swipe travelled in screen units. Will always be at least the difference between + /// and , but will be longer for non-straight lines. + /// + public readonly float TravelDistance; + + /// + /// Duration of the swipe in seconds. + /// + public readonly double SwipeDuration; + + /// + /// A normalized measure of how consistent this swipe was in direction. + /// + public readonly float SwipeSameness; + + /// + /// Construct a new swipe input from a given gesture. + /// + internal SwipeInput(ActiveGesture gesture) : this() + { + InputId = gesture.InputId; + StartPosition = gesture.StartPosition; + PreviousPosition = gesture.PreviousPosition; + EndPosition = gesture.EndPosition; + SwipeDirection = (EndPosition - StartPosition).normalized; + SwipeDuration = gesture.EndTime - gesture.StartTime; + TravelDistance = gesture.TravelDistance; + SwipeSameness = gesture.SwipeDirectionSameness; + + if (SwipeDuration > 0.0f) + { + SwipeVelocity = (float)(TravelDistance / SwipeDuration); + } + } + } +} diff --git a/Runtime/Gestures/SwipeInput.cs.meta b/Runtime/Gestures/SwipeInput.cs.meta new file mode 100644 index 0000000..2d1069f --- /dev/null +++ b/Runtime/Gestures/SwipeInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b454d6ecc4764d5dad9b4839a1f24ad \ No newline at end of file diff --git a/Runtime/NativeUi.meta b/Runtime/NativeUi.meta new file mode 100644 index 0000000..8b71462 --- /dev/null +++ b/Runtime/NativeUi.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2d77a9be33664419e9163bd93546b4ac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs similarity index 99% rename from Runtime/NativeUiService.cs rename to Runtime/NativeUi/NativeUiService.cs index 01a1c83..57dc6e0 100644 --- a/Runtime/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -3,7 +3,7 @@ // ReSharper disable once CheckNamespace -namespace GameLovers.NativeUi +namespace GameLovers.MobileServices.NativeUi { public enum AlertButtonStyle { diff --git a/Runtime/NativeUi/NativeUiService.cs.meta b/Runtime/NativeUi/NativeUiService.cs.meta new file mode 100644 index 0000000..c625719 --- /dev/null +++ b/Runtime/NativeUi/NativeUiService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4db7380d1652a4742ba67abe1dc9c2eb \ No newline at end of file diff --git a/Runtime/Notifications.meta b/Runtime/Notifications.meta new file mode 100644 index 0000000..96c8cc5 --- /dev/null +++ b/Runtime/Notifications.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 48c03ed16911b4f95b405397b757bb61 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/Android/AndroidGameNotification.cs b/Runtime/Notifications/Android/AndroidGameNotification.cs new file mode 100644 index 0000000..45743ef --- /dev/null +++ b/Runtime/Notifications/Android/AndroidGameNotification.cs @@ -0,0 +1,114 @@ +#if UNITY_ANDROID +using System; +using Unity.Notifications.Android; +using UnityEngine.Assertions; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Android specific implementation of . + /// + public class AndroidGameNotification : IGameNotification + { + private AndroidNotification internalNotification; + + /// + /// Gets the internal notification object used by the mobile notifications system. + /// + public AndroidNotification InternalNotification => internalNotification; + + /// + /// + /// On Android, if the ID isn't explicitly set, it will be generated after it has been scheduled. + /// + public int? Id { get; set; } + + /// + public string Title { get => InternalNotification.Title; set => internalNotification.Title = value; } + + /// + public string Body { get => InternalNotification.Text; set => internalNotification.Text = value; } + + /// + /// Does nothing on Android. + /// + public string Subtitle { get => null; set {} } + + /// + /// + /// On Android, this represents the notification's channel, and is required. Will be configured automatically by + /// if is set + /// + /// The value of . + public string Channel { get => DeliveredChannel; set => DeliveredChannel = value; } + + /// + public int? BadgeNumber + { + get => internalNotification.Number != -1 ? internalNotification.Number : (int?)null; + set => internalNotification.Number = value ?? -1; + } + + /// + public bool ShouldAutoCancel + { + get => InternalNotification.ShouldAutoCancel; + set => internalNotification.ShouldAutoCancel = value; + } + + /// + public DateTime? DeliveryTime + { + get => InternalNotification.FireTime; + set => internalNotification.FireTime = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets the channel for this notification. + /// + public string DeliveredChannel { get; set; } + + /// + public bool Scheduled { get; private set; } + + /// + public string SmallIcon { get => InternalNotification.SmallIcon; set => internalNotification.SmallIcon = value; } + + /// + public string LargeIcon { get => InternalNotification.LargeIcon; set => internalNotification.LargeIcon = value; } + + /// + /// Instantiate a new instance of . + /// + public AndroidGameNotification() + { + internalNotification = new AndroidNotification(); + } + + /// + /// Instantiate a new instance of from a delivered notification + /// + /// The notification that has been delivered. + /// The ID of the delivered notification. + /// The channel the notification was delivered to. + internal AndroidGameNotification(AndroidNotification deliveredNotification, int deliveredId, + string deliveredChannel) + { + internalNotification = deliveredNotification; + Id = deliveredId; + DeliveredChannel = deliveredChannel; + } + + /// + /// Set the scheduled flag. + /// + internal void OnScheduled() + { + Assert.IsFalse(Scheduled); + Scheduled = true; + } + } +} +#endif diff --git a/Runtime/NativeUiService.cs.meta b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta similarity index 83% rename from Runtime/NativeUiService.cs.meta rename to Runtime/Notifications/Android/AndroidGameNotification.cs.meta index 96264fc..59db846 100644 --- a/Runtime/NativeUiService.cs.meta +++ b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1411576e1e91848ef92fed2e4388d4d6 +guid: 4fa06f4e8ca11453bb1c9f19541d3f4b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs new file mode 100644 index 0000000..e935582 --- /dev/null +++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs @@ -0,0 +1,182 @@ +#if UNITY_ANDROID +using System; +using System.Linq; +using Unity.Notifications.Android; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Android implementation of . + /// + internal class AndroidNotificationsPlatform : IGameNotificationsPlatform, + IDisposable + { + /// + public event Action NotificationReceived; + + /// + /// Gets or sets the default channel ID for notifications. + /// + /// The default channel ID for new notifications, or null. + public string DefaultChannelId { get; set; } + + /// + /// Instantiate a new instance of . + /// + public AndroidNotificationsPlatform() + { + AndroidNotificationCenter.OnNotificationReceived += OnLocalNotificationReceived; + } + + /// + /// Registers the given for the Android + /// + public void RegisterChannel(GameNotificationChannel notificationChannel) + { + long[] vibrationPattern = null; + if (notificationChannel.VibrationPattern != null) + { + vibrationPattern = notificationChannel.VibrationPattern.Select(v => (long)v).ToArray(); + } + + var channel = new AndroidNotificationChannel(notificationChannel.Id, notificationChannel.Name, + notificationChannel.Description, (Importance)notificationChannel.Style) + { + CanBypassDnd = notificationChannel.HighPriority, + CanShowBadge = notificationChannel.ShowsBadge, + EnableLights = notificationChannel.ShowLights, + EnableVibration = notificationChannel.Vibrates, + LockScreenVisibility = (LockScreenVisibility)notificationChannel.Privacy, + VibrationPattern = vibrationPattern + }; + + AndroidNotificationCenter.RegisterNotificationChannel(channel); + } + + /// + /// + /// Will set the field of . + /// + public void ScheduleNotification(AndroidGameNotification gameNotification) + { + if (gameNotification == null) + { + throw new ArgumentNullException(nameof(gameNotification)); + } + + if (gameNotification.Id.HasValue) + { + AndroidNotificationCenter.SendNotificationWithExplicitID(gameNotification.InternalNotification, + gameNotification.DeliveredChannel, + gameNotification.Id.Value); + } + else + { + int notificationId = AndroidNotificationCenter.SendNotification(gameNotification.InternalNotification, + gameNotification.DeliveredChannel); + gameNotification.Id = notificationId; + } + + gameNotification.OnScheduled(); + } + + /// + /// + /// Will set the field of . + /// + public void ScheduleNotification(IGameNotification gameNotification) + { + if (gameNotification == null) + { + throw new ArgumentNullException(nameof(gameNotification)); + } + + if (!(gameNotification is AndroidGameNotification androidNotification)) + { + throw new InvalidOperationException( + "Notification provided to ScheduleNotification isn't an AndroidGameNotification."); + } + + ScheduleNotification(androidNotification); + } + + /// + /// + /// Create a new . + /// + public AndroidGameNotification CreateNotification() + { + var notification = new AndroidGameNotification() + { + DeliveredChannel = DefaultChannelId + }; + + return notification; + } + + /// + /// + /// Create a new . + /// + IGameNotification IGameNotificationsPlatform.CreateNotification() + { + return CreateNotification(); + } + + /// + public void CancelNotification(int notificationId) + { + AndroidNotificationCenter.CancelScheduledNotification(notificationId); + } + + /// + /// + /// Not currently implemented on Android + /// + public void DismissNotification(int notificationId) + { + AndroidNotificationCenter.CancelDisplayedNotification(notificationId); + } + + /// + public void CancelAllScheduledNotifications() + { + AndroidNotificationCenter.CancelAllScheduledNotifications(); + } + + /// + public void DismissAllDisplayedNotifications() + { + AndroidNotificationCenter.CancelAllDisplayedNotifications(); + } + + /// + /// Does nothing on Android. + /// + public void OnForeground() {} + + /// + /// Does nothing on Android. + /// + public void OnBackground() {} + + /// + /// Unregister delegates. + /// + public void Dispose() + { + AndroidNotificationCenter.OnNotificationReceived -= OnLocalNotificationReceived; + } + + // Event handler for receiving local notifications. + private void OnLocalNotificationReceived(AndroidNotificationIntentData data) + { + // Create a new AndroidGameNotification out of the delivered notification, but only + // if the event is registered + NotificationReceived?.Invoke(new AndroidGameNotification(data.Notification, data.Id, data.Channel)); + } + } +} +#endif diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta new file mode 100644 index 0000000..e4bf190 --- /dev/null +++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76f440eeba915452d8015ca583f8bdcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/GameNotificationChannel.cs b/Runtime/Notifications/GameNotificationChannel.cs new file mode 100644 index 0000000..775a74a --- /dev/null +++ b/Runtime/Notifications/GameNotificationChannel.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Cross-platform wrapper to represent channels for notifications. + /// + /// + /// On Android, this maps pretty closely to Android Notification Channels. On iOS, this does nothing. + /// For projects targeting Android, you need to have at least one channel. + /// + public readonly struct GameNotificationChannel + { + /// + /// The style of notification shown for this channel. Corresponds to the Importance setting of + /// an Android notification, and do nothing on iOS. + /// + public enum NotificationStyle + { + /// + /// Notification does not appear in the status bar. + /// + None = 0, + /// + /// Notification makes no sound. + /// + NoSound = 2, + /// + /// Notification plays sound. + /// + Default = 3, + /// + /// Notification also displays a heads-up popup. + /// + Popup = 4 + } + + /// + /// Controls how notifications display on the device lock screen. + /// + public enum PrivacyMode + { + /// + /// Notifications aren't shown on secure lock screens. + /// + Secret = -1, + /// + /// Notifications display an icon, but content is concealed on secure lock screens. + /// + Private = 0, + /// + /// Notifications display on all lock screens. + /// + Public + } + + /// + /// The identifier for the channel. + /// + public readonly string Id; + + /// + /// The name of the channel as displayed to the user. + /// + public readonly string Name; + + /// + /// The description of the channel as displayed to the user. + /// + public readonly string Description; + + /// + /// A flag determining whether messages on this channel can show a badge. Defaults to true. + /// + public readonly bool ShowsBadge; + + /// + /// A flag determining whether messages on this channel cause the device light to flash. Defaults to false. + /// + public readonly bool ShowLights; + + /// + /// A flag determining whether messages on this channel cause the device to vibrate. Defaults to true. + /// + public readonly bool Vibrates; + + /// + /// A flag determining whether messages on this channel bypass do not disturb settings. Defaults to false. + /// + public readonly bool HighPriority; + + /// + /// The display style for this notification. Defaults to . + /// + public readonly NotificationStyle Style; + + /// + /// The privacy setting for this notification. Defaults to . + /// + public readonly PrivacyMode Privacy; + + /// + /// The custom vibration pattern for this channel. Set to null to use the default. + /// + public readonly int[] VibrationPattern; + + /// + /// Initialize a new instance of with + /// optional fields set to their default values. + /// + public GameNotificationChannel(string id, string name, string description) : this() + { + Id = id; + Name = name; + Description = description; + + ShowsBadge = true; + ShowLights = false; + Vibrates = true; + HighPriority = false; + Style = NotificationStyle.Popup; + Privacy = PrivacyMode.Public; + VibrationPattern = null; + } + + /// + /// Initialize a new instance of , providing the notification style + /// and optionally all other settings. + /// + public GameNotificationChannel(string id, string name, string description, NotificationStyle style, bool showsBadge = true, bool showLights = false, bool vibrates = true, bool highPriority = false, PrivacyMode privacy = PrivacyMode.Public, long[] vibrationPattern = null) + { + Id = id; + Name = name; + Description = description; + ShowsBadge = showsBadge; + ShowLights = showLights; + Vibrates = vibrates; + HighPriority = highPriority; + Style = style; + Privacy = privacy; + if (vibrationPattern != null) + VibrationPattern = vibrationPattern.Select(v => (int)v).ToArray(); + else + VibrationPattern = null; + } + } +} diff --git a/Runtime/Notifications/GameNotificationChannel.cs.meta b/Runtime/Notifications/GameNotificationChannel.cs.meta new file mode 100644 index 0000000..c35f1a4 --- /dev/null +++ b/Runtime/Notifications/GameNotificationChannel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dada65c72c6b34e4aa74dd72ef283fef \ No newline at end of file diff --git a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs new file mode 100644 index 0000000..7617754 --- /dev/null +++ b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// The operating modes for the notifications manager + /// + [Flags] + public enum OperatingMode + { + /// + /// Do not perform any queueing at all. All notifications are scheduled with the operating system + /// immediately. + /// + NoQueue = 0x00, + + /// + /// + /// Queue messages that are scheduled with this manager. + /// No messages will be sent to the operating system until the application is backgrounded. + /// + /// + /// If badge numbers are not set, will automatically increment them. This will only happen if NO badge numbers + /// for pending notifications are ever set. + /// + /// + Queue = 0x01, + + /// + /// When the application is foregrounded, clear all pending notifications. + /// + ClearOnForegrounding = 0x02, + + /// + /// After clearing events, will put future ones back into the queue if they are marked with . + /// + /// + /// Only valid if is also set. + /// + RescheduleAfterClearing = 0x04, + + /// + /// Combines the behaviour of and . + /// + QueueAndClear = Queue | ClearOnForegrounding, + + /// + /// + /// Combines the behaviour of , and + /// . + /// + /// + /// Ensures that messages will never be displayed while the application is in the foreground. + /// + /// + QueueClearAndReschedule = Queue | ClearOnForegrounding | RescheduleAfterClearing, + } + + /// + /// Global notifications manager that serves as a wrapper for multiple platforms' notification systems. + /// + public sealed class GameNotificationsMonoBehaviour : MonoBehaviour + { + + + // Minimum amount of time that a notification should be into the future before it's queued when we background. + private static readonly TimeSpan _minimumNotificationTime = new TimeSpan(0, 0, 2); + + /// + /// The operating mode for the notifications manager + /// + public OperatingMode Mode = OperatingMode.NoQueue; + + /// + /// Check to make the notifications manager automatically set badge numbers so that they increment. + /// Schedule notifications with no numbers manually set to make use of this feature. + /// + public bool AutoBadging = true; + + /// + /// Event fired when a scheduled local notification is delivered while the app is in the foreground. + /// + public Action OnLocalNotificationDelivered; + + /// + /// Event fired when a queued local notification is cancelled because the application is in the foreground + /// when it was meant to be displayed. + /// + /// + public Action OnLocalNotificationExpired; + + private IGameNotificationsPlatform _platform; + private bool _inForeground = true; + + /// + /// Gets a collection of notifications that are scheduled or queued. + /// + public List PendingNotifications { get; private set; } = new List(); + + /// + /// Gets whether this manager has been initialized. + /// + public bool Initialized { get; private set; } + + /// + /// Clean up platform object if necessary + /// + private void OnDestroy() + { + if (_platform == null) + { + return; + } + + _platform.NotificationReceived -= OnNotificationReceived; + if (_platform is IDisposable disposable) + { + disposable.Dispose(); + } + + _inForeground = false; + } + + /// + /// Check pending list for expired notifications, when in queue mode. + /// + private void Update() + { + if ((Mode & OperatingMode.Queue) != OperatingMode.Queue) + { + return; + } + + // Check each pending notification for expiry, then remove it + for (int i = PendingNotifications.Count - 1; i >= 0; --i) + { + PendingNotification queuedNotification = PendingNotifications[i]; + DateTime? time = queuedNotification.Notification.DeliveryTime; + if (time != null && time < DateTime.Now) + { + PendingNotifications.RemoveAt(i); + OnLocalNotificationExpired?.Invoke(queuedNotification); + } + } + } + + /// + /// Respond to application foreground/background events. + /// + private void OnApplicationFocus(bool hasFocus) + { + if (_platform == null || !Initialized) + { + return; + } + + _inForeground = hasFocus; + + if (hasFocus) + { + OnForegrounding(); + + return; + } + + _platform.OnBackground(); + + // Backgrounding. Queue future dated notifications + if ((Mode & OperatingMode.Queue) == OperatingMode.Queue) + { + // Filter out past events + for (var i = PendingNotifications.Count - 1; i >= 0; i--) + { + PendingNotification pendingNotification = PendingNotifications[i]; + // Ignore already scheduled ones + if (pendingNotification.Notification.Scheduled) + { + continue; + } + + // If a non-scheduled notification is in the past (or not within our threshold) + // just remove it immediately + if (pendingNotification.Notification.DeliveryTime != null && + pendingNotification.Notification.DeliveryTime - DateTime.Now < _minimumNotificationTime) + { + PendingNotifications.RemoveAt(i); + } + } + + // Sort notifications by delivery time, if no notifications have a badge number set + bool noBadgeNumbersSet = + PendingNotifications.All(notification => notification.Notification.BadgeNumber == null); + + if (noBadgeNumbersSet && AutoBadging) + { + PendingNotifications.Sort((a, b) => + { + if (!a.Notification.DeliveryTime.HasValue) + { + return 1; + } + + if (!b.Notification.DeliveryTime.HasValue) + { + return -1; + } + + return a.Notification.DeliveryTime.Value.CompareTo(b.Notification.DeliveryTime.Value); + }); + + // Set badge numbers incrementally + var badgeNum = 1; + foreach (var pendingNotification in PendingNotifications) + { + if (pendingNotification.Notification.DeliveryTime.HasValue && + !pendingNotification.Notification.Scheduled) + { + pendingNotification.Notification.BadgeNumber = badgeNum++; + } + } + } + + for (int i = PendingNotifications.Count - 1; i >= 0; i--) + { + var pendingNotification = PendingNotifications[i]; + // Ignore already scheduled ones + if (pendingNotification.Notification.Scheduled) + { + continue; + } + + // Schedule it now + _platform.ScheduleNotification(pendingNotification.Notification); + } + + // Clear badge numbers again (for saving) + if (noBadgeNumbersSet && AutoBadging) + { + foreach (var pendingNotification in PendingNotifications) + { + if (pendingNotification.Notification.DeliveryTime.HasValue) + { + pendingNotification.Notification.BadgeNumber = null; + } + } + } + } + + // Calculate notifications to save + var notificationsToSave = new List(PendingNotifications.Count); + foreach (var pendingNotification in PendingNotifications) + { + // If we're in clear mode, add nothing unless we're in rescheduling mode + // Otherwise add everything + if ((Mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding) + { + if ((Mode & OperatingMode.RescheduleAfterClearing) != OperatingMode.RescheduleAfterClearing) + { + continue; + } + + // In reschedule mode, add ones that have been scheduled, are marked for + // rescheduling, and that have a time + if (pendingNotification.Reschedule && + pendingNotification.Notification.Scheduled && + pendingNotification.Notification.DeliveryTime.HasValue) + { + notificationsToSave.Add(pendingNotification.AsSerializableNotification()); + } + } + else + { + // In non-clear mode, just add all scheduled notifications + if (pendingNotification.Notification.Scheduled) + { + notificationsToSave.Add(pendingNotification.AsSerializableNotification()); + } + } + } + + // Save to disk + PlayerPrefs.SetString("notifications", JsonUtility.ToJson(notificationsToSave)); + } + + /// + /// Initialize the notifications manager. + /// + /// An optional collection of channels to register, for Android + /// has already been called. + public void Initialize(params GameNotificationChannel[] channels) + { + if (Initialized) + { + throw new InvalidOperationException("NotificationsManager already initialized."); + } + + Initialized = true; + +#if UNITY_ANDROID + _platform = new AndroidNotificationsPlatform(); + + // Register the notification channels + var doneDefault = false; + foreach (var notificationChannel in channels) + { + var platform = _platform as AndroidNotificationsPlatform; + + if (!doneDefault) + { + doneDefault = true; + platform.DefaultChannelId = notificationChannel.Id; + } + + platform.RegisterChannel(notificationChannel); + } +#elif UNITY_IOS + _platform = new iOSNotificationsPlatform(); +#endif + + if (_platform == null) + { + return; + } + + _platform.NotificationReceived += OnNotificationReceived; + + OnForegrounding(); + } + + /// + /// Creates a new notification object for the current platform. + /// + /// The new notification, ready to be scheduled, or null if there's no valid platform. + /// has not been called. + public IGameNotification CreateNotification() + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + return _platform?.CreateNotification(); + } + + /// + /// Schedules a notification to be delivered. + /// + /// The notification to deliver. + public PendingNotification ScheduleNotification(IGameNotification notification) + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + if (notification == null) + { + return null; + } + + // If we queue, don't schedule immediately. + // Also immediately schedule non-time based deliveries (for iOS) + if ((Mode & OperatingMode.Queue) != OperatingMode.Queue || notification.DeliveryTime == null) + { + _platform?.ScheduleNotification(notification); + } + else if (!notification.Id.HasValue) + { + // Generate an ID for items that don't have one (just so they can be identified later) + notification.Id = Math.Abs(DateTime.Now.ToString("yyMMddHHmmssffffff").GetHashCode()); + } + + // Register pending notification + var result = new PendingNotification(notification); + PendingNotifications.Add(result); + + return result; + } + + /// + /// Cancels a scheduled notification. + /// + /// The ID of the notification to cancel. + /// has not been called. + public void CancelNotification(int notificationId) + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + if (_platform == null) + { + return; + } + + _platform.CancelNotification(notificationId); + + // Remove the cancelled notification from scheduled list + var index = PendingNotifications.FindIndex(scheduledNotification => + scheduledNotification.Notification.Id == notificationId); + + if (index >= 0) + { + PendingNotifications.RemoveAt(index); + } + } + + /// + /// Cancels all scheduled notifications. + /// + /// has not been called. + public void CancelAllNotifications() + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + if (_platform == null) + { + return; + } + + _platform.CancelAllScheduledNotifications(); + + PendingNotifications.Clear(); + } + + /// + /// Dismisses a displayed notification. + /// + /// The ID of the notification to dismiss. + /// has not been called. + public void DismissNotification(int notificationId) + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + _platform?.DismissNotification(notificationId); + } + + /// + /// Dismisses all displayed notifications. + /// + /// has not been called. + public void DismissAllNotifications() + { + if (!Initialized) + { + throw new InvalidOperationException("Must call Initialize() first."); + } + + _platform?.DismissAllDisplayedNotifications(); + } + + /// + /// Event fired by when a notification is received. + /// + private void OnNotificationReceived(IGameNotification deliveredNotification) + { + // Ignore for background messages (this happens on Android sometimes) + if (!_inForeground) + { + return; + } + + // Find in pending list + int deliveredIndex = PendingNotifications.FindIndex( + scheduledNotification => scheduledNotification.Notification.Id == deliveredNotification.Id); + + if (deliveredIndex >= 0) + { + OnLocalNotificationDelivered?.Invoke(PendingNotifications[deliveredIndex]); + PendingNotifications.RemoveAt(deliveredIndex); + } + } + + // Clear foreground notifications and reschedule stuff from a file + private void OnForegrounding() + { + PendingNotifications.Clear(); + _platform.OnForeground(); + + // Deserialize saved items + var notifications = JsonUtility.FromJson>(PlayerPrefs.GetString("notifications")); + + // Foregrounding + if ((Mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding) + { + // Clear on foregrounding + _platform.CancelAllScheduledNotifications(); + + // Only reschedule in reschedule mode, and if we loaded any items + if (notifications == null || (Mode & OperatingMode.RescheduleAfterClearing) != OperatingMode.RescheduleAfterClearing) + { + return; + } + + // Reschedule notifications from deserialization + foreach (var savedNotification in notifications) + { + if (savedNotification.DeliveryTime > DateTime.Now) + { + var pendingNotification = ScheduleNotification(savedNotification.AsGameNotification(_platform)); + + pendingNotification.Reschedule = true; + } + } + } + else + { + // Just create PendingNotification wrappers for all deserialized items. + // We're not rescheduling them because they were not cleared + if (notifications == null) + { + return; + } + + foreach (var savedNotification in notifications) + { + if (savedNotification.DeliveryTime > DateTime.Now) + { + PendingNotifications.Add(new PendingNotification(savedNotification.AsGameNotification(_platform))); + } + } + } + } + } +} diff --git a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta new file mode 100644 index 0000000..2e5bacd --- /dev/null +++ b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b0d45bfd9ea64de290a68b934db7ad7 \ No newline at end of file diff --git a/Runtime/Notifications/IGameNotification.cs b/Runtime/Notifications/IGameNotification.cs new file mode 100644 index 0000000..07be04c --- /dev/null +++ b/Runtime/Notifications/IGameNotification.cs @@ -0,0 +1,83 @@ +using System; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Represents a notification that will be delivered for this application. + /// + public interface IGameNotification + { + /// + /// Gets or sets a unique identifier for this notification. + /// + /// + /// + /// If null, will be generated automatically once the notification is delivered, and then + /// can be retrieved afterwards. + /// + /// On some platforms, this might be converted to a string identifier internally. + /// + /// A unique integer identifier for this notification, or null (on some platforms) if not explicitly set. + int? Id { get; set; } + + /// + /// Gets or sets the notification's title. + /// + /// The title message for the notification. + string Title { get; set; } + + /// + /// Gets or sets the body text of the notification. + /// + /// The body message for the notification. + string Body { get; set; } + + /// + /// Gets or sets a subtitle for the notification. + /// + /// The subtitle message for the notification. + string Subtitle { get; set; } + + /// + /// Gets or sets channel to which this notification belongs. + /// + /// A platform specific string identifier for the notification's channel. + string Channel { get; set; } + + /// + /// Gets or sets the badge number for this notification. No badge number will be shown if null. + /// + /// The number displayed on the app badge. + int? BadgeNumber { get; set; } + + /// + /// Gets or sets if this notification will be dismissed automatically when the user taps it. + /// Only available on Android. + /// + bool ShouldAutoCancel { get; set; } + + /// + /// Gets or sets time to deliver the notification. + /// + /// The time of delivery in local time. + DateTime? DeliveryTime { get; set; } + + /// + /// Gets whether this notification has been scheduled. + /// + /// True if the notification has been scheduled with the underlying operating system. + bool Scheduled { get; } + + /// + /// Notification small icon. + /// + string SmallIcon { get; set; } + + /// + /// Notification large icon. + /// + string LargeIcon { get; set; } + } +} diff --git a/Runtime/Notifications/IGameNotification.cs.meta b/Runtime/Notifications/IGameNotification.cs.meta new file mode 100644 index 0000000..03ca995 --- /dev/null +++ b/Runtime/Notifications/IGameNotification.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fd2ac3834f914528ba469316784950b \ No newline at end of file diff --git a/Runtime/Notifications/Internal.meta b/Runtime/Notifications/Internal.meta new file mode 100644 index 0000000..244a7dc --- /dev/null +++ b/Runtime/Notifications/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f1a61a43141b448f2bfd863bbef3757a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs b/Runtime/Notifications/Internal/EditorGameNotification.cs new file mode 100644 index 0000000..8d3bc80 --- /dev/null +++ b/Runtime/Notifications/Internal/EditorGameNotification.cs @@ -0,0 +1,35 @@ +using System; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Editor specific implementation of . + /// + internal class EditorGameNotification : IGameNotification + { + /// + public int? Id { get; set; } + /// + public string Title { get; set; } + /// + public string Body { get; set; } + /// + public string Subtitle { get; set; } + /// + public string Channel { get; set; } + /// + public int? BadgeNumber { get; set; } + /// + public bool ShouldAutoCancel { get; set; } + /// + public DateTime? DeliveryTime { get; set; } + /// + public bool Scheduled { get; } + /// + public string SmallIcon { get; set; } + /// + public string LargeIcon { get; set; } + } +} \ No newline at end of file diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs.meta b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta new file mode 100644 index 0000000..a5f0b91 --- /dev/null +++ b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dec8fb3d7dc54e56b11fd43b44ea0aac +timeCreated: 1599154756 \ No newline at end of file diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs new file mode 100644 index 0000000..345fe4d --- /dev/null +++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs @@ -0,0 +1,85 @@ +using System; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Any type that handles notifications for a specific game platform + /// + internal interface IGameNotificationsPlatform + { + /// + /// Fired when a notification is received. + /// + event Action NotificationReceived; + + /// + /// Create a new instance of a for this platform. + /// + /// A new platform-appropriate notification object. + IGameNotification CreateNotification(); + + /// + /// Schedules a notification to be delivered. + /// + /// The notification to deliver. + /// is null. + /// isn't of the correct type. + void ScheduleNotification(IGameNotification gameNotification); + + /// + /// Cancels a scheduled notification. + /// + /// The ID of a previously scheduled notification. + void CancelNotification(int notificationId); + + /// + /// Dismiss a displayed notification. + /// + /// The ID of a previously scheduled notification that is being displayed to the user. + void DismissNotification(int notificationId); + + /// + /// Cancels all scheduled notifications. + /// + void CancelAllScheduledNotifications(); + + /// + /// Dismisses all displayed notifications. + /// + void DismissAllDisplayedNotifications(); + + /// + /// Performs any initialization or processing necessary on foregrounding the application. + /// + void OnForeground(); + + /// + /// Performs any processing necessary on backgrounding or closing the application. + /// + void OnBackground(); + } + + /// + /// Any type that handles notifications for a specific game platform. + /// + /// Has a concrete notification type + /// The type of notification returned by this platform. + internal interface IGameNotificationsPlatform : IGameNotificationsPlatform + where TNotificationType : IGameNotification + { + /// + /// Create an instance of . + /// + /// A new platform-appropriate notification object. + new TNotificationType CreateNotification(); + + /// + /// Schedule a notification to be delivered. + /// + /// The notification to deliver. + /// is null. + void ScheduleNotification(TNotificationType notification); + } +} diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta new file mode 100644 index 0000000..a7a67b2 --- /dev/null +++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 61be2413974834a019a5d8c5b37dde4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs b/Runtime/Notifications/Internal/SerializableNotification.cs new file mode 100644 index 0000000..11b61b6 --- /dev/null +++ b/Runtime/Notifications/Internal/SerializableNotification.cs @@ -0,0 +1,58 @@ +using System; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Notification to serialize/deserialize to disk when the game goes foreground + /// + [Serializable] + internal struct SerializableNotification + { + public int? Id; + public string Title; + public string Body; + public string Subtitle; + public string Channel; + public int? BadgeNumber; + public DateTime? DeliveryTime; + } + + /// + /// Converter serialization classes + /// + internal static class SerializableNotificationConverter + { + public static IGameNotification AsGameNotification(this SerializableNotification serializableNotification, + IGameNotificationsPlatform platform) + { + var notification = platform.CreateNotification(); + + notification.Id = serializableNotification.Id; + notification.Title = serializableNotification.Title; + notification.Body = serializableNotification.Body; + notification.Subtitle = serializableNotification.Subtitle; + notification.Channel = serializableNotification.Channel; + notification.BadgeNumber = serializableNotification.BadgeNumber; + notification.DeliveryTime = serializableNotification.DeliveryTime; + + return notification; + } + + public static SerializableNotification AsSerializableNotification(this PendingNotification pendingNotification) + { + return new SerializableNotification + { + Id = pendingNotification.Notification.Id, + Title = pendingNotification.Notification.Title, + Body = pendingNotification.Notification.Body, + Subtitle = pendingNotification.Notification.Subtitle, + Channel = pendingNotification.Notification.Channel, + BadgeNumber = pendingNotification.Notification.BadgeNumber, + DeliveryTime = pendingNotification.Notification.DeliveryTime, + }; + } + } + +} \ No newline at end of file diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs.meta b/Runtime/Notifications/Internal/SerializableNotification.cs.meta new file mode 100644 index 0000000..bb1bbea --- /dev/null +++ b/Runtime/Notifications/Internal/SerializableNotification.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fe50232782284ad7a604b234c205ff63 +timeCreated: 1596468382 \ No newline at end of file diff --git a/Runtime/Notifications/MobileNotificationService.cs b/Runtime/Notifications/MobileNotificationService.cs new file mode 100644 index 0000000..dcb0ed8 --- /dev/null +++ b/Runtime/Notifications/MobileNotificationService.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// This service allows to schedule and handle notifications on the current platform + /// + public interface INotificationService + { + /// + /// Event fired when a scheduled local notification is delivered while the app is in the foreground. + /// + event Action OnLocalNotificationDeliveredEvent; + + /// + /// Event fired when a queued local notification is cancelled because the application is in the foreground + /// when it was meant to be displayed. + /// + /// + event Action OnLocalNotificationExpiredEvent; + + /// + /// Gets a collection of notifications that are scheduled or queued. + /// + IReadOnlyList PendingNotifications { get; } + + /// + /// Create a new instance of a for this platform. + /// + /// A new platform-appropriate notification object. + IGameNotification CreateNotification(); + + /// + /// Schedules a notification to be delivered. + /// + /// The notification to deliver. + /// is null. + /// isn't of the correct type. + PendingNotification ScheduleNotification(IGameNotification gameNotification); + + /// + /// Cancels a scheduled notification. + /// + /// The ID of a previously scheduled notification. + void CancelNotification(int notificationId); + + /// + /// Dismiss a displayed notification. + /// + /// The ID of a previously scheduled notification that is being displayed to the user. + void DismissNotification(int notificationId); + + /// + /// Cancels all scheduled notifications. + /// + void CancelAllScheduledNotifications(); + + /// + /// Dismisses all displayed notifications. + /// + void DismissAllDisplayedNotifications(); + } + + /// + public class MobileNotificationService : INotificationService + { + private readonly GameNotificationsMonoBehaviour _monoBehaviour; + + /// + public event Action OnLocalNotificationDeliveredEvent; + /// + public event Action OnLocalNotificationExpiredEvent; + + /// + public IReadOnlyList PendingNotifications => _monoBehaviour.PendingNotifications; + + public MobileNotificationService(params GameNotificationChannel[] channels) + { + _monoBehaviour = new GameObject("NotificationService").AddComponent(); + _monoBehaviour.OnLocalNotificationDelivered = OnLocalNotificationDeliveredEvent; + _monoBehaviour.OnLocalNotificationExpired = OnLocalNotificationExpiredEvent; + + _monoBehaviour.Initialize(channels); + UnityEngine.Object.DontDestroyOnLoad(_monoBehaviour); + } + + /// + public IGameNotification CreateNotification() + { +#if UNITY_EDITOR + return new EditorGameNotification(); +#else + return _monoBehaviour.CreateNotification(); +#endif + } + + /// + public PendingNotification ScheduleNotification(IGameNotification gameNotification) + { +#if UNITY_EDITOR + if (!gameNotification.Id.HasValue) + { + // Generate an ID for items that don't have one (just so they can be identified later) + gameNotification.Id = Math.Abs(DateTime.Now.ToString("yyMMddHHmmssffffff").GetHashCode()); + } + return new PendingNotification(gameNotification); +#else + return _monoBehaviour.ScheduleNotification(gameNotification); +#endif + } + + /// + public void CancelNotification(int notificationId) + { + _monoBehaviour.CancelNotification(notificationId); + } + + /// + public void DismissNotification(int notificationId) + { + _monoBehaviour.DismissNotification(notificationId); + } + + /// + public void CancelAllScheduledNotifications() + { + _monoBehaviour.CancelAllNotifications(); + } + + /// + public void DismissAllDisplayedNotifications() + { + _monoBehaviour.DismissAllNotifications(); + } + } +} \ No newline at end of file diff --git a/Runtime/Notifications/MobileNotificationService.cs.meta b/Runtime/Notifications/MobileNotificationService.cs.meta new file mode 100644 index 0000000..7b5a805 --- /dev/null +++ b/Runtime/Notifications/MobileNotificationService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04122135a6ed94669aa8f08ba2169106 \ No newline at end of file diff --git a/Runtime/Notifications/PendingNotification.cs b/Runtime/Notifications/PendingNotification.cs new file mode 100644 index 0000000..37d8f48 --- /dev/null +++ b/Runtime/Notifications/PendingNotification.cs @@ -0,0 +1,41 @@ +using System; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Represents a notification that was scheduled with . + /// + public class PendingNotification + { + /// + /// Whether to reschedule this event if it hasn't displayed once the app is foregrounded again. + /// + /// + /// + /// Only valid if the 's + /// flag is set to . + /// + /// + /// Will not function for any notifications that are using a delivery scheduling method that isn't time + /// based, such as iOS location notifications. + /// + /// + public bool Reschedule; + + /// + /// The scheduled notification. + /// + public readonly IGameNotification Notification; + + /// + /// Instantiate a new instance of from a . + /// + /// The notification to create from. + public PendingNotification(IGameNotification notification) + { + Notification = notification ?? throw new ArgumentNullException(nameof(notification)); + } + } +} diff --git a/Runtime/Notifications/PendingNotification.cs.meta b/Runtime/Notifications/PendingNotification.cs.meta new file mode 100644 index 0000000..b9b4a84 --- /dev/null +++ b/Runtime/Notifications/PendingNotification.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2ae73b3fa5e343cbbfbc118ba2940f2 \ No newline at end of file diff --git a/Runtime/Notifications/iOS.meta b/Runtime/Notifications/iOS.meta new file mode 100644 index 0000000..5c2990a --- /dev/null +++ b/Runtime/Notifications/iOS.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4db05989dfd8343c4a92c1cd81843ec4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs b/Runtime/Notifications/iOS/iOSGameNotification.cs new file mode 100644 index 0000000..15231e2 --- /dev/null +++ b/Runtime/Notifications/iOS/iOSGameNotification.cs @@ -0,0 +1,182 @@ +#if UNITY_IOS +using System; +using Unity.Notifications.iOS; +using UnityEngine; +using UnityEngine.Assertions; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// iOS implementation of . + /// + public class iOSGameNotification : IGameNotification + { + private readonly iOSNotification internalNotification; + + /// + /// Gets the internal notification object used by the mobile notifications system. + /// + public iOSNotification InternalNotification => internalNotification; + + /// + /// + /// Internally stored as a string. Gets parsed to an integer when retrieving. + /// + /// The identifier as an integer, or null if the identifier couldn't be parsed as a number. + public int? Id + { + get + { + if (!int.TryParse(internalNotification.Identifier, out int value)) + { + Debug.LogWarning("Internal iOS notification's identifier isn't a number."); + return null; + } + + return value; + } + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + internalNotification.Identifier = value.Value.ToString(); + } + } + + /// + public string Title { get => internalNotification.Title; set => internalNotification.Title = value; } + + /// + public string Body { get => internalNotification.Body; set => internalNotification.Body = value; } + + /// + public string Subtitle { get => internalNotification.Subtitle; set => internalNotification.Subtitle = value; } + + /// + /// + /// On iOS, this represents the notification's Category Identifier. + /// + /// The value of . + public string Channel { get => CategoryIdentifier; set => CategoryIdentifier = value; } + + /// + public int? BadgeNumber + { + get => internalNotification.Badge != -1 ? internalNotification.Badge : (int?)null; + set => internalNotification.Badge = value ?? -1; + } + + /// + public bool ShouldAutoCancel { get; set; } + + /// + public bool Scheduled { get; private set; } + + /// + /// + /// On iOS, setting this causes the notification to be delivered on a calendar time. + /// If it has previously been manually set to a different type of trigger, or has not been set before, + /// this returns null. + /// The millisecond component of the provided DateTime is ignored. + /// + /// A representing the delivery time of this message, or null if + /// not set or the trigger isn't a . + public DateTime? DeliveryTime + { + get + { + if (!(internalNotification.Trigger is iOSNotificationCalendarTrigger calendarTrigger)) + { + return null; + } + + DateTime now = DateTime.Now; + var result = new DateTime + ( + calendarTrigger.Year ?? now.Year, + calendarTrigger.Month ?? now.Month, + calendarTrigger.Day ?? now.Day, + calendarTrigger.Hour ?? now.Hour, + calendarTrigger.Minute ?? now.Minute, + calendarTrigger.Second ?? now.Second, + DateTimeKind.Local + ); + + return result; + } + set + { + if (!value.HasValue) + { + return; + } + + DateTime date = value.Value.ToLocalTime(); + + internalNotification.Trigger = new iOSNotificationCalendarTrigger + { + Year = date.Year, + Month = date.Month, + Day = date.Day, + Hour = date.Hour, + Minute = date.Minute, + Second = date.Second + }; + } + } + + /// + /// The category identifier for this notification. + /// + public string CategoryIdentifier + { + get => internalNotification.CategoryIdentifier; + set => internalNotification.CategoryIdentifier = value; + } + + /// + /// Does nothing on iOS. + /// + public string SmallIcon { get => null; set {} } + + /// + /// Does nothing on iOS. + /// + public string LargeIcon { get => null; set {} } + + /// + /// Instantiate a new instance of . + /// + public iOSGameNotification() + { + internalNotification = new iOSNotification + { + ShowInForeground = true // Deliver in foreground by default + }; + } + + /// + /// Instantiate a new instance of from a delivered notification. + /// + /// The delivered notification. + internal iOSGameNotification(iOSNotification internalNotification) + { + this.internalNotification = internalNotification; + } + + /// + /// Mark this notifications scheduled flag. + /// + internal void OnScheduled() + { + Assert.IsFalse(Scheduled); + Scheduled = true; + } + } +} +#endif diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs.meta b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta new file mode 100644 index 0000000..aed2281 --- /dev/null +++ b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ead874ace75948419f27b714f72f6c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs new file mode 100644 index 0000000..df5dbb3 --- /dev/null +++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs @@ -0,0 +1,126 @@ +#if UNITY_IOS +using System; +using Unity.Notifications.iOS; + +// ReSharper disable once CheckNamespace + +namespace GameLovers.MobileServices.Notifications +{ + /// + /// iOS implementation of . + /// + internal class iOSNotificationsPlatform : IGameNotificationsPlatform, IDisposable + { + /// + public event Action NotificationReceived; + + /// + /// Instantiate a new instance of . + /// + public iOSNotificationsPlatform() + { + iOSNotificationCenter.OnNotificationReceived += OnLocalNotificationReceived; + } + + /// + public void ScheduleNotification(IGameNotification gameNotification) + { + if (gameNotification == null) + { + throw new ArgumentNullException(nameof(gameNotification)); + } + + if (!(gameNotification is iOSGameNotification notification)) + { + throw new InvalidOperationException( + "Notification provided to ScheduleNotification isn't an iOSGameNotification."); + } + + ScheduleNotification(notification); + } + + /// + public void ScheduleNotification(iOSGameNotification notification) + { + if (notification == null) + { + throw new ArgumentNullException(nameof(notification)); + } + + iOSNotificationCenter.ScheduleNotification(notification.InternalNotification); + notification.OnScheduled(); + } + + /// + /// + /// Create a new . + /// + IGameNotification IGameNotificationsPlatform.CreateNotification() + { + return CreateNotification(); + } + + /// + /// + /// Create a new . + /// + public iOSGameNotification CreateNotification() + { + return new iOSGameNotification(); + } + + /// + public void CancelNotification(int notificationId) + { + iOSNotificationCenter.RemoveScheduledNotification(notificationId.ToString()); + } + + /// + public void DismissNotification(int notificationId) + { + iOSNotificationCenter.RemoveDeliveredNotification(notificationId.ToString()); + } + + /// + public void CancelAllScheduledNotifications() + { + iOSNotificationCenter.RemoveAllScheduledNotifications(); + } + + /// + public void DismissAllDisplayedNotifications() + { + iOSNotificationCenter.RemoveAllDeliveredNotifications(); + } + + /// + /// Clears badge count. + /// + public void OnForeground() + { + iOSNotificationCenter.ApplicationBadge = 0; + } + + /// + /// Does nothing on iOS. + /// + public void OnBackground() {} + + /// + /// Unregister delegates. + /// + public void Dispose() + { + iOSNotificationCenter.OnNotificationReceived -= OnLocalNotificationReceived; + } + + // Event handler for receiving local notifications. + private void OnLocalNotificationReceived(iOSNotification notification) + { + // Create a new AndroidGameNotification out of the delivered notification, but only + // if the event is registered + NotificationReceived?.Invoke(new iOSGameNotification(notification)); + } + } +} +#endif diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta new file mode 100644 index 0000000..8b2cd6d --- /dev/null +++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1a93181c78954935999e28b0c6a7893 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 525e61b..a424017 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,14 @@ { - "name": "com.gamelovers.nativeui", - "displayName": "Native UI", - "version": "0.2.5", - "unity": "2020.3", - "description": "This package provides the OS functionality to show the native UI alerts, Game Review PopUp and message Toasts", - "type": "library" -} \ No newline at end of file + "name": "com.gamelovers.mobileservices", + "displayName": "Mobile Services", + "author": "Miguel Tomas", + "version": "1.0.0", + "unity": "6000.0", + "license": "MIT", + "description": "Mobile platform services for Unity: native UI (alerts/toasts), push notifications, and gesture detection (swipe/drag).", + "type": "library", + "dependencies": { + "com.unity.mobile.notifications": "2.3.0", + "com.unity.inputsystem": "1.11.0" + } +} From 8cdc99a4e03ce5151d798b6e27c72c363cd6b279 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 14 Jan 2026 15:19:06 +0000 Subject: [PATCH 02/32] docs: AGETNS.md and README.md files updated to match the latest project state docs: add MIT LICENSE disclaimer to this project --- AGENTS.md | 185 +++++++++++---- LICENSE.md | 7 + README.md | 444 +++++++++++++++++++++++++++++++++--- Third Party Notices.md | 16 -- Third Party Notices.md.meta | 7 - 5 files changed, 556 insertions(+), 103 deletions(-) create mode 100644 LICENSE.md delete mode 100644 Third Party Notices.md delete mode 100644 Third Party Notices.md.meta diff --git a/AGENTS.md b/AGENTS.md index 5413c82..ff04821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,49 +3,142 @@ ## 1. Package Overview - **Package**: `com.gamelovers.mobileservices` - **Unity**: 6000.0+ -- **Dependencies**: - - `com.unity.mobile.notifications` (2.3.0) - - `com.unity.inputsystem` (1.11.0) - -This package consolidates mobile-specific platform services: Native UI integration, Push Notifications, and Advanced Gesture detection. - -## 2. Runtime Architecture - -### Native UI (`Runtime/NativeUi/`) -- **Main Class**: `NativeUiService` (static) -- **Responsibility**: Bridge to native iOS and Android UI components (Alerts, Sheets, Toasts). -- **Implementation**: Uses `AndroidJavaClass` for Android and `[DllImport("__Internal")]` for iOS (linked via `Plugins/iOS/NativeUi.m`). - -### Notifications (`Runtime/Notifications/`) -- **Main Interface**: `INotificationService` -- **Concrete Class**: `MobileNotificationService` -- **Responsibility**: Wrapper around Unity's Mobile Notifications package. -- **Key Flow**: - - `MobileNotificationService` spawns a `GameNotificationsMonoBehaviour` host GameObject. - - Channels must be configured during initialization. - - Supports platform-specific notification shapes via `IGameNotification`. - -### Gestures (`Runtime/Gestures/`) -- **Main Class**: `GestureController` (MonoBehaviour) -- **Responsibility**: Interprets pointer input to detect complex gestures (Swipes). -- **Key Concepts**: - - `ActiveGesture`: Internal state tracking for a single pointer. - - `SwipeInput`: Data structure containing swipe direction, velocity, and "sameness" (consistency). - - Uses `PointerInputManager` to abstract Input System pointer data. - -## 3. Directory Structure -- `Runtime/NativeUi/`: Native bridge code for C#. -- `Runtime/Notifications/`: Notification management logic. -- `Runtime/Gestures/`: Gesture detection algorithms and Input System integration. -- `Plugins/iOS/`: Native Objective-C code for iOS bridging. -- `Tests/`: Unit and integration tests. - -## 4. Coding Standards -- **Namespaces**: Use `GameLovers.MobileServices.*` sub-namespaces. -- **Platform Defines**: Use `#if UNITY_IOS`, `#if UNITY_ANDROID`, and `#if UNITY_EDITOR` appropriately. -- **Async**: Favor async/await where possible, though many native calls are synchronous or event-based. - -## 5. Migration Logic -This package replaces `com.gamelovers.nativeui`, `com.gamelovers.notificationservice`, and parts of `com.gamelovers.inputextensions`. -- Always update old references to use the new unified assembly: `GameLovers.MobileServices`. -- Namespace mapping is documented in `MIGRATION.md`. +- **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) and toast-style messages. +- **Notifications**: platform wrapper over Unity Mobile Notifications (Android/iOS). +- **Gestures**: Input System–based pointer abstraction + swipe/tap detection. + +For user-facing docs, treat `README.md` as the primary entry point. 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` and `android.widget.Toast`. + - iOS: uses `[DllImport("__Internal")]` native functions implemented in `Plugins/iOS/NativeUi.m`. +- **Button model**: `AlertButton` + `AlertButtonStyle`. + +### 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`). + +### Gestures (`GameLovers.MobileServices.Gestures`) +- **Input abstraction** + - `Runtime/Gestures/PointerInputManager.cs` converts Input System callbacks into higher-level events: `Pressed`, `Dragged`, `Released`. + - Input actions live under `Runtime/Gestures/Controls/PointerControls.inputactions` and are generated into `PointerControls.cs`. + - `Runtime/Gestures/PointerInput.cs` defines the transport struct (`PointerInput`) and a binding composite (`PointerInputComposite`) used by the input action setup. +- **Gesture detection** + - `Runtime/Gestures/GestureController.cs` consumes `PointerInputManager` and emits gesture events (`Pressed`, `PotentiallySwiped`, `Swiped`, `Tapped`). + - `Runtime/Gestures/ActiveGesture.cs` is the internal state accumulator per pointer id. + - `Runtime/Gestures/SwipeInput.cs` is the public data structure for swipe output. + +## 3. Key Directories / Files +``` +Runtime/ +├── NativeUi/ +│ └── NativeUiService.cs +├── Notifications/ +│ ├── MobileNotificationService.cs +│ ├── GameNotificationsMonoBehaviour.cs +│ ├── GameNotificationChannel.cs +│ ├── IGameNotification.cs +│ ├── PendingNotification.cs +│ ├── Android/ +│ │ ├── AndroidNotificationsPlatform.cs +│ │ └── AndroidGameNotification.cs +│ ├── iOS/ +│ │ ├── iOSNotificationsPlatform.cs +│ │ └── iOSGameNotification.cs +│ └── Internal/ +│ ├── IGameNotificationsPlatform.cs +│ ├── EditorGameNotification.cs +│ └── SerializableNotification.cs +├── Gestures/ +│ ├── GestureController.cs +│ ├── PointerInputManager.cs +│ ├── ActiveGesture.cs +│ ├── SwipeInput.cs +│ ├── PointerInput.cs +│ └── Controls/ +│ ├── PointerControls.inputactions +│ └── PointerControls.cs +└── GameLovers.MobileServices.asmdef + +Plugins/iOS/ +└── NativeUi.m +``` + +## 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. + - `PointerInputManager` requires `PointerControls` to be enabled (it enables/disables in `OnEnable`/`OnDisable`). + +## 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. + - If modifying pointer bindings, update `PointerControls.inputactions` and regenerate `PointerControls.cs` from the Input System editor. + +## 8. Update Policy +Update this file when: +- Public API changes (`NativeUiService`, `INotificationService`, `IGameNotification`, `GestureController` events) +- Platform integration changes (JNI calls, iOS native symbols, notification platform wrappers) +- Notification queueing/persistence behavior changes (`OperatingMode`, PlayerPrefs payload shape) +- Input System bindings or generated controls change (`PointerControls.inputactions`, `PointerControls.cs`) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6b83528 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2025 Miguel Tomás + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 9c7aefa..f3ed5c3 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,464 @@ -# Mobile Services +# GameLovers Mobile Services [![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/badge/version-1.0.0-green.svg)](CHANGELOG.md) -## Overview +> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-documentation) | [Contributing](#contributing) -**Mobile Services** is a consolidated package providing essential platform-specific services for Unity mobile projects. It simplifies native integration for UI, notifications, and advanced touch gestures. +## Why Use This Package? -This package consolidates three legacy packages into a single, cohesive foundation: -- **Native UI**: Alerts, toasts, and game review prompts. -- **Notifications**: Local and remote push notification management. -- **Gestures**: Advanced swipe and drag detection (extracted from legacy inputextensions). +Building mobile-specific features in Unity often requires dealing with platform-specific code, native bridges, and fragmented APIs. This **Mobile Services** package consolidates essential mobile functionality into a unified, easy-to-use API: ---- +| Problem | Solution | +|---------|----------| +| **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, and review prompts with one API | +| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management | +| **Custom gesture detection** | Gesture controller provides swipe detection with velocity, direction, and consistency metrics | +| **Input System boilerplate** | Pointer input manager abstracts touch/mouse input across platforms | +| **Editor testing challenges** | Editor fallbacks for all features enable testing without device builds | + +**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Clean platform abstractions. Tested in real mobile games. -## Key Features +### Key Features -- **🎭 Native UI Service** - Call native OS dialogs and toasts without writing platform-specific code. -- **📨 Notification Service** - Schedule, cancel, and manage local/remote notifications with ease. -- **👆 Gesture Controller** - Robust swipe detection with velocity, direction, and consistency metrics. -- **📱 Platform Optimized** - Built specifically for iOS and Android with editor fallbacks. -- **⚡ Async Ready** - Modern C# implementation designed for high performance. +- **🎭 Native UI Service** - Call native OS dialogs, action sheets, and toasts without platform-specific code +- **📨 Notification Service** - Schedule, cancel, and manage local/remote notifications with channel support +- **👆 Gesture Controller** - Robust swipe detection with velocity, direction, and consistency metrics +- **📱 Platform Optimized** - Built specifically for iOS and Android with editor fallbacks +- **🔧 Input System Integration** - Modern pointer input abstraction using Unity Input System --- +## System Requirements + +- **[Unity](https://unity.com/download)** 6000.0+ (Unity 6) +- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) - Automatically resolved +- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) - Automatically resolved + +### Compatibility Matrix + +| Unity Version | Status | Notes | +|---------------|--------|-------| +| 6000.0+ (Unity 6) | ✅ Fully Tested | Primary development target | +| 2022.3 LTS | ⚠️ Untested | May require minor adaptations | + +| Platform | Status | Notes | +|----------|--------|-------| +| iOS | ✅ Supported | Full feature support | +| Android | ✅ Supported | Full feature support | +| Editor | ✅ Supported | Fallbacks for testing | +| Standalone | ⚠️ Limited | Gestures only; no native UI/notifications | +| WebGL | ❌ Not Supported | Mobile-only features | + ## Installation -### Via Unity Package Manager (UPM) +### Via Unity Package Manager (Recommended) -1. Open Unity Package Manager (`Window` → `Package Manager`). -2. Click the `+` button and select `Add package from git URL`. +1. Open Unity Package Manager (`Window` → `Package Manager`) +2. Click the `+` button and select `Add package from git URL` 3. Enter the following URL: ``` https://github.com/CoderGamester/com.gamelovers.mobileservices.git ``` +### Via manifest.json + +Add the following line to your project's `Packages/manifest.json`: + +```json +{ + "dependencies": { + "com.gamelovers.mobileservices": "https://github.com/CoderGamester/com.gamelovers.mobileservices.git" + } +} +``` + +--- + +## Package Structure + +``` +Runtime/ +├── NativeUi/ +│ └── NativeUiService.cs # Static native UI bridge (alerts, toasts) +├── Notifications/ +│ ├── MobileNotificationService.cs # Main notification manager +│ ├── IGameNotification.cs # Notification abstraction +│ ├── GameNotificationChannel.cs # Channel configuration +│ ├── PendingNotification.cs # Scheduled notification wrapper +│ ├── Android/ # Android-specific implementation +│ ├── iOS/ # iOS-specific implementation +│ └── Internal/ # Platform abstraction internals +└── Gestures/ + ├── GestureController.cs # MonoBehaviour for gesture detection + ├── SwipeInput.cs # Swipe data structure + ├── ActiveGesture.cs # Gesture state tracking + ├── PointerInputManager.cs # Input System abstraction + └── Controls/ # Input action definitions + +Plugins/ +└── iOS/ + └── NativeUi.m # Objective-C native bridge +``` + +### Key Components + +| Component | Responsibility | +|-----------|----------------| +| **NativeUiService** | Static class bridging native iOS/Android UI (alerts, action sheets, toasts) | +| **MobileNotificationService** | Notification scheduling, cancellation, and channel management | +| **IGameNotification** | Platform-agnostic notification interface | +| **GestureController** | MonoBehaviour detecting swipe gestures with configurable thresholds | +| **SwipeInput** | Data structure with swipe direction, velocity, and consistency metrics | +| **PointerInputManager** | Input System wrapper for touch/mouse pointer abstraction | + --- ## Quick Start ### 1. Native UI + ```csharp using GameLovers.MobileServices.NativeUi; // Show a simple alert -NativeUiService.ShowAlertPopUp(false, "Welcome", "Thank you for playing!", - new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); +NativeUiService.ShowAlertPopUp( + darkMode: false, + title: "Welcome", + message: "Thank you for playing!", + new AlertButton { Text = "OK", Style = AlertButtonStyle.Default } +); + +// Show an alert with multiple buttons +NativeUiService.ShowAlertPopUp( + darkMode: true, + title: "Delete Save?", + message: "This action cannot be undone.", + new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, + new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, OnClick = OnDeleteConfirmed } +); // Show a toast message (Android only) -NativeUiService.ShowToastMessage("Item Collected", false); +NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); + +// Request app store review +NativeUiService.RequestReview(); ``` +--- + ### 2. Notifications + ```csharp using GameLovers.MobileServices.Notifications; -// Initialize service with channels -var service = new MobileNotificationService(new GameNotificationChannel("default", "Default", "Default Channel")); +public class NotificationManager : MonoBehaviour +{ + private MobileNotificationService _notificationService; + + void Awake() + { + // Initialize with notification channels + _notificationService = new MobileNotificationService( + new GameNotificationChannel("default", "Default", "Default notifications"), + new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") + ); + } + + public void ScheduleDailyReward() + { + // Create and configure notification + var notification = _notificationService.CreateNotification(); + notification.Title = "Daily Reward Ready!"; + notification.Body = "Your daily reward is waiting for you!"; + notification.DeliveryTime = DateTime.Now.AddHours(24); + notification.SmallIcon = "icon_reward"; + notification.Channel = "rewards"; + + // Schedule it + _notificationService.ScheduleNotification(notification); + } + + public void CancelAllNotifications() + { + _notificationService.CancelAllNotifications(); + } + + void OnApplicationPause(bool pauseStatus) + { + if (pauseStatus) + { + // Schedule reminder when app is backgrounded + ScheduleDailyReward(); + } + } +} +``` + +--- + +### 3. Gesture Detection + +```csharp +using UnityEngine; +using GameLovers.MobileServices.Gestures; + +public class SwipeHandler : MonoBehaviour +{ + [SerializeField] private GestureController _gestureController; + + void OnEnable() + { + _gestureController.Swiped += OnSwipe; + } + + void OnDisable() + { + _gestureController.Swiped -= OnSwipe; + } + + private void OnSwipe(SwipeInput swipe) + { + Debug.Log($"Swiped {swipe.SwipeDirection}"); + Debug.Log($"Velocity: {swipe.SwipeVelocity}"); + Debug.Log($"Sameness: {swipe.SwipeSameness}"); // Direction consistency (0-1) + + switch (swipe.SwipeDirection) + { + case SwipeDirection.Left: + // Handle left swipe + break; + case SwipeDirection.Right: + // Handle right swipe + break; + case SwipeDirection.Up: + // Handle up swipe + break; + case SwipeDirection.Down: + // Handle down swipe + break; + } + } +} +``` + +--- + +## Services Documentation + +### Native UI Service + +Static service bridging native iOS and Android UI components. + +**Key Points:** +- All methods are **static** - no initialization required +- Uses `AndroidJavaClass` for Android, `[DllImport]` for iOS +- Editor provides fallback implementations for testing -// Schedule a notification +```csharp +using GameLovers.MobileServices.NativeUi; + +// Alert popup with callback +NativeUiService.ShowAlertPopUp( + darkMode: false, + title: "Confirm Purchase", + message: "Buy 100 gems for $0.99?", + new AlertButton + { + Text = "Cancel", + Style = AlertButtonStyle.Cancel + }, + new AlertButton + { + Text = "Buy", + Style = AlertButtonStyle.Default, + OnClick = () => ProcessPurchase() + } +); + +// Toast (Android only, no-op on iOS) +NativeUiService.ShowToastMessage("Saved!", isLongDuration: false); + +// App Store / Play Store review prompt +NativeUiService.RequestReview(); +``` + +**Alert Button Styles:** +- `Default` - Standard button appearance +- `Cancel` - Cancel/dismiss style +- `Destructive` - Red/warning style for destructive actions + +--- + +### Notification Service + +Wrapper around Unity Mobile Notifications with simplified channel and scheduling API. + +**Key Points:** +- Requires channel configuration at initialization +- Creates a `DontDestroyOnLoad` host GameObject (`GameNotificationsMonoBehaviour`) +- Supports platform-specific notification features via `IGameNotification` + +```csharp +using GameLovers.MobileServices.Notifications; + +// Initialize with channels +var service = new MobileNotificationService( + new GameNotificationChannel("general", "General", "General notifications"), + new GameNotificationChannel("promo", "Promotions", "Promotional offers") +); + +// Create notification var notification = service.CreateNotification(); -notification.Title = "Daily Reward"; -notification.Body = "Your reward is ready!"; -notification.DeliveryTime = DateTime.Now.AddHours(24); -service.ScheduleNotification(notification); +notification.Title = "Special Offer!"; +notification.Body = "50% off all items today only!"; +notification.DeliveryTime = DateTime.Now.AddMinutes(30); +notification.Channel = "promo"; +notification.BadgeNumber = 1; + +// Schedule +var pending = service.ScheduleNotification(notification); + +// Cancel specific notification +service.CancelNotification(pending.Id); + +// Cancel all +service.CancelAllNotifications(); + +// Get pending notifications +var scheduled = service.GetPendingNotifications(); ``` -### 3. Swipe Gestures +**IGameNotification Properties:** +| Property | Description | +|----------|-------------| +| `Title` | Notification title text | +| `Body` | Notification body text | +| `DeliveryTime` | When to deliver (DateTime) | +| `Channel` | Channel ID for grouping | +| `SmallIcon` | Icon resource name | +| `LargeIcon` | Large icon resource name | +| `BadgeNumber` | App badge count | + +--- + +### Gesture Controller + +MonoBehaviour for detecting swipe gestures with configurable sensitivity. + +**Key Points:** +- Attach to a GameObject in your scene +- Raises `Swiped` event with detailed swipe data +- Configurable via inspector or code + ```csharp +using UnityEngine; using GameLovers.MobileServices.Gestures; -// Attach GestureController to a GameObject and listen to events -gestureController.Swiped += (swipe) => { - Debug.Log($"Swiped {swipe.SwipeDirection} with velocity {swipe.SwipeVelocity}"); -}; +public class CardSwiper : MonoBehaviour +{ + [SerializeField] private GestureController _gestureController; + + void Start() + { + _gestureController.Swiped += HandleSwipe; + } + + void OnDestroy() + { + _gestureController.Swiped -= HandleSwipe; + } + + private void HandleSwipe(SwipeInput swipe) + { + // swipe.SwipeDirection - Up, Down, Left, Right + // swipe.SwipeVelocity - Speed of the swipe + // swipe.SwipeSameness - How consistent the direction was (0-1) + // swipe.StartPosition - Where the swipe started + // swipe.EndPosition - Where the swipe ended + + if (swipe.SwipeSameness > 0.8f) // Clean, intentional swipe + { + ProcessSwipe(swipe.SwipeDirection); + } + } +} ``` +**SwipeInput Fields:** +| Field | Type | Description | +|-------|------|-------------| +| `SwipeDirection` | `SwipeDirection` | Detected direction (Up/Down/Left/Right) | +| `SwipeVelocity` | `float` | Speed of the swipe gesture | +| `SwipeSameness` | `float` | Direction consistency (0-1, higher = cleaner swipe) | +| `StartPosition` | `Vector2` | Screen position where swipe started | +| `EndPosition` | `Vector2` | Screen position where swipe ended | + +--- + +## Platform-Specific Notes + +### iOS + +- Native UI uses Objective-C bridge (`Plugins/iOS/NativeUi.m`) +- Notifications require iOS notification permissions +- Review prompts use `SKStoreReviewController` + +### Android + +- Native UI uses `AndroidJavaClass` reflection +- Toast messages use native Android Toast API +- Notifications support notification channels (Android 8.0+) +- Review prompts use Play In-App Review API + +### Editor + +- Alert popups log to console (or use Unity dialog if available) +- Toast messages log to console +- Notifications are simulated (logged but not scheduled) +- Gestures work with mouse input + --- -## Migration from Legacy Packages +## Contributing + +We welcome contributions! Here's how you can help: + +### Reporting Issues + +- Use the [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) page +- Include Unity version, package version, and reproduction steps +- Specify target platform (iOS/Android) and device info +- Attach relevant code samples, error logs, or screenshots -If you are migrating from the old separate packages, please refer to [MIGRATION.md](MIGRATION.md) for detailed namespace and API mapping changes. +### Development Setup + +1. Fork the repository on GitHub +2. Clone your fork: `git clone https://github.com/yourusername/com.gamelovers.mobileservices.git` +3. Create a feature branch: `git checkout -b feature/amazing-feature` +4. Make your changes with tests +5. Commit: `git commit -m 'Add amazing feature'` +6. Push: `git push origin feature/amazing-feature` +7. Create a Pull Request + +### Code Guidelines + +- Follow C# 9.0 syntax with explicit namespaces (no global usings) +- Add XML documentation to all public APIs +- Use platform defines: `#if UNITY_IOS`, `#if UNITY_ANDROID`, `#if UNITY_EDITOR` +- Include unit tests for new features +- Runtime code must not reference `UnityEditor` +- Update CHANGELOG.md for notable changes --- +## Support + +- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) +- **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/com.gamelovers.mobileservices/discussions) +- **Changelog**: See [CHANGELOG.md](CHANGELOG.md) for version history + ## License This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. @@ -92,3 +466,5 @@ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md --- **Made with ❤️ for the Unity community** + +*If this package helps your project, please consider giving it a ⭐ on GitHub!* diff --git a/Third Party Notices.md b/Third Party Notices.md deleted file mode 100644 index 32bb0a8..0000000 --- a/Third Party Notices.md +++ /dev/null @@ -1,16 +0,0 @@ -This package contains third-party software components governed by the license(s) indicated below: ---------- - -Component Name: [provide component name] - -License Type: [Provide license type, i.e. "MIT", "Apache 2.0"] - -[Provide License Details] - ---------- -Component Name: [provide component name] - -License Type: [Provide license type, i.e. "MIT", "Apache 2.0"] - -[Provide License Details] - diff --git a/Third Party Notices.md.meta b/Third Party Notices.md.meta deleted file mode 100644 index bc950b7..0000000 --- a/Third Party Notices.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 03bee7ce4f04a414999552da5575a7ee -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From e10e0390403c5b95e3f51dd2bc6247f77ae78ee0 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 14 Jan 2026 15:41:25 +0000 Subject: [PATCH 03/32] chore: add license meta file --- LICENSE.md.meta | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 LICENSE.md.meta diff --git a/LICENSE.md.meta b/LICENSE.md.meta new file mode 100644 index 0000000..87e2c58 --- /dev/null +++ b/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 96dbb61d72e02415d94268a13a538e50 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From ffeb5797745b6d060d29e25bbcb6114cc1d585b2 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Wed, 14 Jan 2026 16:22:34 +0000 Subject: [PATCH 04/32] refactor(gestures): modernize system using EnhancedTouch API feat(gestures): add TapInput struct for tap detection docs(gestures): update agent guide for new gesture architecture chore(gestures): remove obsolete input abstraction files --- AGENTS.md | 22 +- Runtime/Gestures/Controls.meta | 8 - Runtime/Gestures/Controls/PointerControls.cs | 603 ------------------ .../Gestures/Controls/PointerControls.cs.meta | 2 - .../Controls/PointerControls.inputactions | 477 -------------- .../PointerControls.inputactions.meta | 14 - Runtime/Gestures/GestureController.cs | 98 ++- Runtime/Gestures/PointerInput.cs | 115 ---- Runtime/Gestures/PointerInput.cs.meta | 2 - Runtime/Gestures/PointerInputManager.cs | 126 ---- Runtime/Gestures/PointerInputManager.cs.meta | 2 - Runtime/Gestures/TapInput.cs | 22 + Runtime/Gestures/TapInput.cs.meta | 2 + 13 files changed, 96 insertions(+), 1397 deletions(-) delete mode 100644 Runtime/Gestures/Controls.meta delete mode 100755 Runtime/Gestures/Controls/PointerControls.cs delete mode 100644 Runtime/Gestures/Controls/PointerControls.cs.meta delete mode 100755 Runtime/Gestures/Controls/PointerControls.inputactions delete mode 100644 Runtime/Gestures/Controls/PointerControls.inputactions.meta delete mode 100755 Runtime/Gestures/PointerInput.cs delete mode 100644 Runtime/Gestures/PointerInput.cs.meta delete mode 100755 Runtime/Gestures/PointerInputManager.cs delete mode 100644 Runtime/Gestures/PointerInputManager.cs.meta create mode 100644 Runtime/Gestures/TapInput.cs create mode 100644 Runtime/Gestures/TapInput.cs.meta diff --git a/AGENTS.md b/AGENTS.md index ff04821..777e39b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,14 +41,12 @@ For user-facing docs, treat `README.md` as the primary entry point. This file is - Android requires at least one channel to be registered; the first channel passed becomes the platform default (`AndroidNotificationsPlatform.DefaultChannelId`). ### Gestures (`GameLovers.MobileServices.Gestures`) -- **Input abstraction** - - `Runtime/Gestures/PointerInputManager.cs` converts Input System callbacks into higher-level events: `Pressed`, `Dragged`, `Released`. - - Input actions live under `Runtime/Gestures/Controls/PointerControls.inputactions` and are generated into `PointerControls.cs`. - - `Runtime/Gestures/PointerInput.cs` defines the transport struct (`PointerInput`) and a binding composite (`PointerInputComposite`) used by the input action setup. +- **Input source**: Unity's `EnhancedTouch` API (`Touch.onFingerDown/Move/Up`) - **Gesture detection** - - `Runtime/Gestures/GestureController.cs` consumes `PointerInputManager` and emits gesture events (`Pressed`, `PotentiallySwiped`, `Swiped`, `Tapped`). - - `Runtime/Gestures/ActiveGesture.cs` is the internal state accumulator per pointer id. + - `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. ## 3. Key Directories / Files ``` @@ -73,13 +71,9 @@ Runtime/ │ └── SerializableNotification.cs ├── Gestures/ │ ├── GestureController.cs -│ ├── PointerInputManager.cs │ ├── ActiveGesture.cs │ ├── SwipeInput.cs -│ ├── PointerInput.cs -│ └── Controls/ -│ ├── PointerControls.inputactions -│ └── PointerControls.cs +│ └── TapInput.cs └── GameLovers.MobileServices.asmdef Plugins/iOS/ @@ -104,7 +98,8 @@ Plugins/iOS/ - 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. - - `PointerInputManager` requires `PointerControls` to be enabled (it enables/disables in `OnEnable`/`OnDisable`). + - `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. ## 5. Coding Standards (Unity 6 / C# 9.0) - **C#**: C# 9.0 syntax; explicit namespaces; no global usings. @@ -134,11 +129,10 @@ When you need third-party source/docs, prefer the locally-cached UPM packages: - Ensure at least one channel is registered; confirm default channel behavior matches expectations. - **Change gesture detection** - Adjust thresholds on `GestureController` and document intended UX. - - If modifying pointer bindings, update `PointerControls.inputactions` and regenerate `PointerControls.cs` from the Input System editor. ## 8. Update Policy Update this file when: - Public API changes (`NativeUiService`, `INotificationService`, `IGameNotification`, `GestureController` events) - Platform integration changes (JNI calls, iOS native symbols, notification platform wrappers) - Notification queueing/persistence behavior changes (`OperatingMode`, PlayerPrefs payload shape) -- Input System bindings or generated controls change (`PointerControls.inputactions`, `PointerControls.cs`) +- Gesture detection logic or input source integration changes diff --git a/Runtime/Gestures/Controls.meta b/Runtime/Gestures/Controls.meta deleted file mode 100644 index ce3ca77..0000000 --- a/Runtime/Gestures/Controls.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 17a4bb9d298724559b013147e0bb9d14 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/Gestures/Controls/PointerControls.cs b/Runtime/Gestures/Controls/PointerControls.cs deleted file mode 100755 index 60163d9..0000000 --- a/Runtime/Gestures/Controls/PointerControls.cs +++ /dev/null @@ -1,603 +0,0 @@ -// GENERATED AUTOMATICALLY FROM 'Packages/com.gamelovers.inputextension/Runtime/Controls/PointerControls.inputactions' - -using System; -using System.Collections; -using System.Collections.Generic; -using UnityEngine.InputSystem; -using UnityEngine.InputSystem.Utilities; - -namespace GameLovers.MobileServices.Gestures.Controls -{ - public class @PointerControls : IInputActionCollection, IDisposable - { - public InputActionAsset asset { get; } - public @PointerControls() - { - asset = InputActionAsset.FromJson(@"{ - ""name"": ""PointerControls"", - ""maps"": [ - { - ""name"": ""pointer"", - ""id"": ""3c570214-6b14-44a9-8e61-3e4dc9ac469f"", - ""actions"": [ - { - ""name"": ""point"", - ""type"": ""Value"", - ""id"": ""4d610105-c5af-439c-8a02-4f1976d8da67"", - ""expectedControlType"": """", - ""processors"": """", - ""interactions"": """" - } - ], - ""bindings"": [ - { - ""name"": ""MouseAndPen"", - ""id"": ""6503119b-11d7-4b61-9465-8ab83699a36c"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""33cce31d-cbc6-4899-8781-9ab727534e60"", - ""path"": ""/leftButton"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Mouse"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""contact"", - ""id"": ""418f64e8-359e-4b05-8869-c8a1165e44d9"", - ""path"": ""/tip"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Pen"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""60ae03ce-5f16-4763-9102-400558002a23"", - ""path"": ""/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Mouse"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""4d35537c-6a23-4f4a-bad4-eaeed0a67248"", - ""path"": ""/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Pen"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""tilt"", - ""id"": ""e14524a1-8951-4672-98aa-49fda32a7548"", - ""path"": ""/tilt"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Pen"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""ce154ce8-174d-4bbf-adb7-4f8634b86a24"", - ""path"": ""/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Pen"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""twist"", - ""id"": ""8f871c6e-49c6-4e0b-9d0f-65263a53ac84"", - ""path"": ""/twist"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Pen"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""Touch0"", - ""id"": ""f5819de4-b7e5-4745-9d93-2b45e9dad897"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""a0baebac-8b22-4db8-9cf9-7ba8c4d8aab0"", - ""path"": ""/touch0/press"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""79b4615b-9534-4aa7-af27-90a442add4bc"", - ""path"": ""/touch0/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""radius"", - ""id"": ""07bde460-f80a-45ae-823d-db51f6bda4bb"", - ""path"": ""/touch0/radius"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""b72c4336-bd65-457f-bacd-cf9933eb2fc7"", - ""path"": ""/touch0/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""inputId"", - ""id"": ""6be63793-ef04-469d-ac12-779245b71ba9"", - ""path"": ""/touch0/touchId"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""Touch1"", - ""id"": ""448ef65c-b779-4014-bdc4-1cf793b11223"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""a7e9f275-8e72-4221-b947-e1d972629254"", - ""path"": ""/touch1/press"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""64552938-2fd4-4fd9-aab5-c6873527f9fa"", - ""path"": ""/touch1/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""radius"", - ""id"": ""ccf25457-9cab-4137-bfb6-d9a3f07cdf02"", - ""path"": ""/touch1/radius"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""1cd9d53e-4d19-4f1d-a4cf-68df808d026a"", - ""path"": ""/touch1/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""inputId"", - ""id"": ""583bf8d0-0d65-4042-9496-64d4f3b7b7e2"", - ""path"": ""/touch1/touchId"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""Touch2"", - ""id"": ""19c33421-5a1b-427d-a668-3c9e57862d40"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""70013879-1601-4e2d-959f-ac99c1e74af5"", - ""path"": ""/touch2/press"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""392b2a12-03e3-42b4-9c70-89e916237cd0"", - ""path"": ""/touch2/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""radius"", - ""id"": ""301637a3-d0e1-4181-a288-53c2f7057337"", - ""path"": ""/touch2/radius"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""28c1c15f-64bb-4129-89a3-67814ae5f3a6"", - ""path"": ""/touch2/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""inputId"", - ""id"": ""8b632dbd-6147-4928-8c90-2d2f64667fcb"", - ""path"": ""/touch2/touchId"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""Touch3"", - ""id"": ""05259a7b-4240-446d-acb5-e4a580f53994"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""3e7de849-5b93-4126-84a4-2d03d20ef368"", - ""path"": ""/touch3/press"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""120da9ee-f4b2-49a7-8a29-d4e1f94f7031"", - ""path"": ""/touch3/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""radius"", - ""id"": ""29d3a1b9-e12a-470a-8ea1-9993d5b53e87"", - ""path"": ""/touch3/radius"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""7209bbd8-72a4-440a-911a-f2f271b586a8"", - ""path"": ""/touch3/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""inputId"", - ""id"": ""7f7a6001-89d2-4947-86b9-00d9d24dadec"", - ""path"": ""/touch3/touchId"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""Touch4"", - ""id"": ""1b233d85-1ef7-4af4-ac02-9615393f52c4"", - ""path"": ""PointerInput"", - ""interactions"": """", - ""processors"": """", - ""groups"": """", - ""action"": ""point"", - ""isComposite"": true, - ""isPartOfComposite"": false - }, - { - ""name"": ""contact"", - ""id"": ""53bdf954-414a-499c-bc07-8f4f09f3ad58"", - ""path"": ""/touch4/press"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""position"", - ""id"": ""a39e1415-77d5-4303-be04-1046a4923ced"", - ""path"": ""/touch4/position"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""radius"", - ""id"": ""13406675-5622-4525-8fbc-528295a19a0e"", - ""path"": ""/touch4/radius"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""pressure"", - ""id"": ""23c29f3f-f6ec-474f-b6af-ceb385bac764"", - ""path"": ""/touch4/pressure"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - }, - { - ""name"": ""inputId"", - ""id"": ""3aafb622-08a2-4a5f-ba33-2def589b35a2"", - ""path"": ""/touch4/touchId"", - ""interactions"": """", - ""processors"": """", - ""groups"": "";Touch"", - ""action"": ""point"", - ""isComposite"": false, - ""isPartOfComposite"": true - } - ] - } - ], - ""controlSchemes"": [ - { - ""name"": ""Mouse"", - ""bindingGroup"": ""Mouse"", - ""devices"": [ - { - ""devicePath"": """", - ""isOptional"": false, - ""isOR"": false - } - ] - }, - { - ""name"": ""Pen"", - ""bindingGroup"": ""Pen"", - ""devices"": [ - { - ""devicePath"": """", - ""isOptional"": false, - ""isOR"": false - } - ] - }, - { - ""name"": ""Touch"", - ""bindingGroup"": ""Touch"", - ""devices"": [ - { - ""devicePath"": """", - ""isOptional"": false, - ""isOR"": false - } - ] - } - ] -}"); - // pointer - m_pointer = asset.FindActionMap("pointer", throwIfNotFound: true); - m_pointer_point = m_pointer.FindAction("point", throwIfNotFound: true); - } - - public void Dispose() - { - UnityEngine.Object.Destroy(asset); - } - - public InputBinding? bindingMask - { - get => asset.bindingMask; - set => asset.bindingMask = value; - } - - public ReadOnlyArray? devices - { - get => asset.devices; - set => asset.devices = value; - } - - public ReadOnlyArray controlSchemes => asset.controlSchemes; - - public bool Contains(InputAction action) - { - return asset.Contains(action); - } - - public IEnumerator GetEnumerator() - { - return asset.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public void Enable() - { - asset.Enable(); - } - - public void Disable() - { - asset.Disable(); - } - - // pointer - private readonly InputActionMap m_pointer; - private IPointerActions m_PointerActionsCallbackInterface; - private readonly InputAction m_pointer_point; - public struct PointerActions - { - private @PointerControls m_Wrapper; - public PointerActions(@PointerControls wrapper) { m_Wrapper = wrapper; } - public InputAction @point => m_Wrapper.m_pointer_point; - public InputActionMap Get() { return m_Wrapper.m_pointer; } - public void Enable() { Get().Enable(); } - public void Disable() { Get().Disable(); } - public bool enabled => Get().enabled; - public static implicit operator InputActionMap(PointerActions set) { return set.Get(); } - public void SetCallbacks(IPointerActions instance) - { - if (m_Wrapper.m_PointerActionsCallbackInterface != null) - { - @point.started -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; - @point.performed -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; - @point.canceled -= m_Wrapper.m_PointerActionsCallbackInterface.OnPoint; - } - m_Wrapper.m_PointerActionsCallbackInterface = instance; - if (instance != null) - { - @point.started += instance.OnPoint; - @point.performed += instance.OnPoint; - @point.canceled += instance.OnPoint; - } - } - } - public PointerActions @pointer => new PointerActions(this); - private int m_MouseSchemeIndex = -1; - public InputControlScheme MouseScheme - { - get - { - if (m_MouseSchemeIndex == -1) m_MouseSchemeIndex = asset.FindControlSchemeIndex("Mouse"); - return asset.controlSchemes[m_MouseSchemeIndex]; - } - } - private int m_PenSchemeIndex = -1; - public InputControlScheme PenScheme - { - get - { - if (m_PenSchemeIndex == -1) m_PenSchemeIndex = asset.FindControlSchemeIndex("Pen"); - return asset.controlSchemes[m_PenSchemeIndex]; - } - } - private int m_TouchSchemeIndex = -1; - public InputControlScheme TouchScheme - { - get - { - if (m_TouchSchemeIndex == -1) m_TouchSchemeIndex = asset.FindControlSchemeIndex("Touch"); - return asset.controlSchemes[m_TouchSchemeIndex]; - } - } - public interface IPointerActions - { - void OnPoint(InputAction.CallbackContext context); - } - } -} diff --git a/Runtime/Gestures/Controls/PointerControls.cs.meta b/Runtime/Gestures/Controls/PointerControls.cs.meta deleted file mode 100644 index bc4fb51..0000000 --- a/Runtime/Gestures/Controls/PointerControls.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 18cb32298728641fd9449640aecd7101 \ No newline at end of file diff --git a/Runtime/Gestures/Controls/PointerControls.inputactions b/Runtime/Gestures/Controls/PointerControls.inputactions deleted file mode 100755 index 193256f..0000000 --- a/Runtime/Gestures/Controls/PointerControls.inputactions +++ /dev/null @@ -1,477 +0,0 @@ -{ - "name": "PointerControls", - "maps": [ - { - "name": "pointer", - "id": "3c570214-6b14-44a9-8e61-3e4dc9ac469f", - "actions": [ - { - "name": "point", - "type": "Value", - "id": "4d610105-c5af-439c-8a02-4f1976d8da67", - "expectedControlType": "", - "processors": "", - "interactions": "" - } - ], - "bindings": [ - { - "name": "MouseAndPen", - "id": "6503119b-11d7-4b61-9465-8ab83699a36c", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "33cce31d-cbc6-4899-8781-9ab727534e60", - "path": "/leftButton", - "interactions": "", - "processors": "", - "groups": ";Mouse", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "contact", - "id": "418f64e8-359e-4b05-8869-c8a1165e44d9", - "path": "/tip", - "interactions": "", - "processors": "", - "groups": ";Pen", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "60ae03ce-5f16-4763-9102-400558002a23", - "path": "/position", - "interactions": "", - "processors": "", - "groups": ";Mouse", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "4d35537c-6a23-4f4a-bad4-eaeed0a67248", - "path": "/position", - "interactions": "", - "processors": "", - "groups": ";Pen", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "tilt", - "id": "e14524a1-8951-4672-98aa-49fda32a7548", - "path": "/tilt", - "interactions": "", - "processors": "", - "groups": ";Pen", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "ce154ce8-174d-4bbf-adb7-4f8634b86a24", - "path": "/pressure", - "interactions": "", - "processors": "", - "groups": ";Pen", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "twist", - "id": "8f871c6e-49c6-4e0b-9d0f-65263a53ac84", - "path": "/twist", - "interactions": "", - "processors": "", - "groups": ";Pen", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "Touch0", - "id": "f5819de4-b7e5-4745-9d93-2b45e9dad897", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "a0baebac-8b22-4db8-9cf9-7ba8c4d8aab0", - "path": "/touch0/press", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "79b4615b-9534-4aa7-af27-90a442add4bc", - "path": "/touch0/position", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "radius", - "id": "07bde460-f80a-45ae-823d-db51f6bda4bb", - "path": "/touch0/radius", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "b72c4336-bd65-457f-bacd-cf9933eb2fc7", - "path": "/touch0/pressure", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "inputId", - "id": "6be63793-ef04-469d-ac12-779245b71ba9", - "path": "/touch0/touchId", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "Touch1", - "id": "448ef65c-b779-4014-bdc4-1cf793b11223", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "a7e9f275-8e72-4221-b947-e1d972629254", - "path": "/touch1/press", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "64552938-2fd4-4fd9-aab5-c6873527f9fa", - "path": "/touch1/position", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "radius", - "id": "ccf25457-9cab-4137-bfb6-d9a3f07cdf02", - "path": "/touch1/radius", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "1cd9d53e-4d19-4f1d-a4cf-68df808d026a", - "path": "/touch1/pressure", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "inputId", - "id": "583bf8d0-0d65-4042-9496-64d4f3b7b7e2", - "path": "/touch1/touchId", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "Touch2", - "id": "19c33421-5a1b-427d-a668-3c9e57862d40", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "70013879-1601-4e2d-959f-ac99c1e74af5", - "path": "/touch2/press", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "392b2a12-03e3-42b4-9c70-89e916237cd0", - "path": "/touch2/position", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "radius", - "id": "301637a3-d0e1-4181-a288-53c2f7057337", - "path": "/touch2/radius", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "28c1c15f-64bb-4129-89a3-67814ae5f3a6", - "path": "/touch2/pressure", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "inputId", - "id": "8b632dbd-6147-4928-8c90-2d2f64667fcb", - "path": "/touch2/touchId", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "Touch3", - "id": "05259a7b-4240-446d-acb5-e4a580f53994", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "3e7de849-5b93-4126-84a4-2d03d20ef368", - "path": "/touch3/press", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "120da9ee-f4b2-49a7-8a29-d4e1f94f7031", - "path": "/touch3/position", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "radius", - "id": "29d3a1b9-e12a-470a-8ea1-9993d5b53e87", - "path": "/touch3/radius", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "7209bbd8-72a4-440a-911a-f2f271b586a8", - "path": "/touch3/pressure", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "inputId", - "id": "7f7a6001-89d2-4947-86b9-00d9d24dadec", - "path": "/touch3/touchId", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "Touch4", - "id": "1b233d85-1ef7-4af4-ac02-9615393f52c4", - "path": "PointerInput", - "interactions": "", - "processors": "", - "groups": "", - "action": "point", - "isComposite": true, - "isPartOfComposite": false - }, - { - "name": "contact", - "id": "53bdf954-414a-499c-bc07-8f4f09f3ad58", - "path": "/touch4/press", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "position", - "id": "a39e1415-77d5-4303-be04-1046a4923ced", - "path": "/touch4/position", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "radius", - "id": "13406675-5622-4525-8fbc-528295a19a0e", - "path": "/touch4/radius", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "pressure", - "id": "23c29f3f-f6ec-474f-b6af-ceb385bac764", - "path": "/touch4/pressure", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - }, - { - "name": "inputId", - "id": "3aafb622-08a2-4a5f-ba33-2def589b35a2", - "path": "/touch4/touchId", - "interactions": "", - "processors": "", - "groups": ";Touch", - "action": "point", - "isComposite": false, - "isPartOfComposite": true - } - ] - } - ], - "controlSchemes": [ - { - "name": "Mouse", - "basedOn": "", - "bindingGroup": "Mouse", - "devices": [ - { - "devicePath": "", - "isOptional": false, - "isOR": false - } - ] - }, - { - "name": "Pen", - "basedOn": "", - "bindingGroup": "Pen", - "devices": [ - { - "devicePath": "", - "isOptional": false, - "isOR": false - } - ] - }, - { - "name": "Touch", - "basedOn": "", - "bindingGroup": "Touch", - "devices": [ - { - "devicePath": "", - "isOptional": false, - "isOR": false - } - ] - } - ] -} \ No newline at end of file diff --git a/Runtime/Gestures/Controls/PointerControls.inputactions.meta b/Runtime/Gestures/Controls/PointerControls.inputactions.meta deleted file mode 100644 index 269851c..0000000 --- a/Runtime/Gestures/Controls/PointerControls.inputactions.meta +++ /dev/null @@ -1,14 +0,0 @@ -fileFormatVersion: 2 -guid: 3cb71a3684e884cb6976650d8c1b1063 -ScriptedImporter: - internalIDToNameTable: [] - externalObjects: {} - serializedVersion: 2 - userData: - assetBundleName: - assetBundleVariant: - script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3} - generateWrapperCode: 0 - wrapperCodePath: - wrapperClassName: - wrapperCodeNamespace: diff --git a/Runtime/Gestures/GestureController.cs b/Runtime/Gestures/GestureController.cs index af87af5..8f867ff 100755 --- a/Runtime/Gestures/GestureController.cs +++ b/Runtime/Gestures/GestureController.cs @@ -2,52 +2,53 @@ using System.Collections.Generic; using System.Text; using UnityEngine; +using UnityEngine.InputSystem.EnhancedTouch; +using UnityEngine.Serialization; using UnityEngine.UI; +using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch; namespace GameLovers.MobileServices.Gestures { /// - /// Controller that interprets takes pointer input from and detects + /// Controller that interprets takes pointer input from and detects /// directional swipes and detects taps. /// public class GestureController : MonoBehaviour { - [SerializeField] - private PointerInputManager inputManager; - // Maximum duration of a press before it can no longer be considered a tap. [SerializeField] - private float maxTapDuration = 0.2f; + private float _maxTapDuration = 0.2f; // Maximum distance in screen units that a tap can drift from its original position before // it is no longer considered a tap. [SerializeField] - private float maxTapDrift = 5.0f; + private float _maxTapDrift = 5.0f; // Maximum duration of a swipe before it is no longer considered to be a valid swipe. [SerializeField] - private float maxSwipeDuration = 0.5f; + private float _maxSwipeDuration = 0.5f; // Minimum distance in screen units that a swipe must move before it is considered a swipe. // Note that if this is smaller or equal to maxTapDrift, then it is possible for a user action to be // returned as both a swipe and a tap. [SerializeField] - private float minSwipeDistance = 10.0f; + private float _minSwipeDistance = 10.0f; // How much a swipe should consistently be in the same direction before it is considered a swipe. [SerializeField] - private float swipeDirectionSamenessThreshold = 0.6f; + private float _swipeDirectionSamenessThreshold = 0.6f; + [FormerlySerializedAs("label")] [Header("Debug"), SerializeField] - private Text label; + private Text _label; // Mapping of input IDs to their active gesture tracking objects. - private readonly Dictionary activeGestures = new Dictionary(); + private readonly Dictionary _activeGestures = new Dictionary(); /// /// Event fired when the user presses on the screen. /// - public new event Action Pressed; + public event Action Pressed; /// /// Event fired for every motion (possibly multiple times a frame) of a potential swipe gesture. @@ -64,11 +65,38 @@ public class GestureController : MonoBehaviour /// public event Action Tapped; - protected virtual void Awake() + protected virtual void OnEnable() + { + EnhancedTouchSupport.Enable(); + Touch.onFingerDown += OnFingerDown; + Touch.onFingerMove += OnFingerMove; + Touch.onFingerUp += OnFingerUp; + } + + protected virtual void OnDisable() + { + Touch.onFingerDown -= OnFingerDown; + Touch.onFingerMove -= OnFingerMove; + Touch.onFingerUp -= OnFingerUp; + EnhancedTouchSupport.Disable(); + } + + private void OnFingerDown(Finger finger) { - inputManager.Pressed += OnPressed; - inputManager.Dragged += OnDragged; - inputManager.Released += OnReleased; + var touch = finger.currentTouch; + OnPressed(finger.index, touch.screenPosition, touch.time); + } + + private void OnFingerMove(Finger finger) + { + var touch = finger.currentTouch; + OnDragged(finger.index, touch.screenPosition, touch.time); + } + + private void OnFingerUp(Finger finger) + { + var touch = finger.currentTouch; + OnReleased(finger.index, touch.screenPosition, touch.time); } /// @@ -76,9 +104,9 @@ protected virtual void Awake() /// private bool IsValidSwipe(ref ActiveGesture gesture) { - return gesture.TravelDistance >= minSwipeDistance && - (gesture.StartTime - gesture.EndTime) <= maxSwipeDuration && - gesture.SwipeDirectionSameness >= swipeDirectionSamenessThreshold; + return gesture.TravelDistance >= _minSwipeDistance && + (gesture.EndTime - gesture.StartTime) <= _maxSwipeDuration && + gesture.SwipeDirectionSameness >= _swipeDirectionSamenessThreshold; } /// @@ -86,31 +114,31 @@ private bool IsValidSwipe(ref ActiveGesture gesture) /// private bool IsValidTap(ref ActiveGesture gesture) { - return gesture.TravelDistance <= maxTapDrift && - (gesture.StartTime - gesture.EndTime) <= maxTapDuration; + return gesture.TravelDistance <= _maxTapDrift && + (gesture.EndTime - gesture.StartTime) <= _maxTapDuration; } - private void OnPressed(PointerInput input, double time) + private void OnPressed(int inputId, Vector2 position, double time) { - Debug.Assert(!activeGestures.ContainsKey(input.InputId)); + Debug.Assert(!_activeGestures.ContainsKey(inputId)); - var newGesture = new ActiveGesture(input.InputId, input.Position, time); - activeGestures.Add(input.InputId, newGesture); + var newGesture = new ActiveGesture(inputId, position, time); + _activeGestures.Add(inputId, newGesture); DebugInfo(newGesture); Pressed?.Invoke(new SwipeInput(newGesture)); } - private void OnDragged(PointerInput input, double time) + private void OnDragged(int inputId, Vector2 position, double time) { - if (!activeGestures.TryGetValue(input.InputId, out var existingGesture)) + if (!_activeGestures.TryGetValue(inputId, out var existingGesture)) { // Probably caught by UI, or the input was otherwise lost return; } - existingGesture.SubmitPoint(input.Position, time); + existingGesture.SubmitPoint(position, time); if (IsValidSwipe(ref existingGesture)) { @@ -120,16 +148,16 @@ private void OnDragged(PointerInput input, double time) DebugInfo(existingGesture); } - private void OnReleased(PointerInput input, double time) + private void OnReleased(int inputId, Vector2 position, double time) { - if (!activeGestures.TryGetValue(input.InputId, out var existingGesture)) + if (!_activeGestures.TryGetValue(inputId, out var existingGesture)) { // Probably caught by UI, or the input was otherwise lost return; } - activeGestures.Remove(input.InputId); - existingGesture.SubmitPoint(input.Position, time); + _activeGestures.Remove(inputId); + existingGesture.SubmitPoint(position, time); if (IsValidSwipe(ref existingGesture)) { @@ -146,7 +174,7 @@ private void OnReleased(PointerInput input, double time) private void DebugInfo(ActiveGesture gesture) { - if (label == null) return; + if (_label == null) return; var builder = new StringBuilder(); @@ -171,7 +199,9 @@ private void DebugInfo(ActiveGesture gesture) builder.AppendFormat("Ending Timestamp: {0}", gesture.EndTime); builder.AppendLine(); - label.text = builder.ToString(); + _label.text = builder.ToString(); + + if (Camera.main == null) return; var worldStart = Camera.main.ScreenToWorldPoint(gesture.StartPosition); var worldEnd = Camera.main.ScreenToWorldPoint(gesture.EndPosition); diff --git a/Runtime/Gestures/PointerInput.cs b/Runtime/Gestures/PointerInput.cs deleted file mode 100755 index f4cd220..0000000 --- a/Runtime/Gestures/PointerInput.cs +++ /dev/null @@ -1,115 +0,0 @@ -using UnityEngine; -using UnityEngine.InputSystem; -using UnityEngine.InputSystem.Layouts; -using UnityEngine.InputSystem.Utilities; - -namespace GameLovers.MobileServices.Gestures -{ - /// - /// Simple object to contain information for drag inputs. - /// - public struct PointerInput - { - public bool Contact; - - /// - /// ID of input type. - /// - public int InputId; - - /// - /// Position of draw input. - /// - public Vector2 Position; - - /// - /// Orientation of draw input pen. - /// - public Vector2? Tilt; - - /// - /// Pressure of draw input. - /// - public float? Pressure; - - /// - /// Radius of draw input. - /// - public Vector2? Radius; - - /// - /// Twist of draw input. - /// - public float? Twist; - } - - // What we do in PointerInputManager is to simply create a separate action for each input we need for PointerInput. - // This here shows a possible alternative that sources all inputs as a single value using a composite. Has pros - // and cons. Biggest pro is that all the controls actuate together and deliver one input value. - // - // NOTE: In PointerControls, we are binding mouse and pen separately from touch. If we didn't care about multitouch, - // we wouldn't have to to that but could rather just bind `/position` etc. However, to source each touch - // as its own separate PointerInput source, we need to have multiple PointerInputComposites. - #if UNITY_EDITOR - [UnityEditor.InitializeOnLoad] - #endif - public class PointerInputComposite : InputBindingComposite - { - [InputControl(layout = "Button")] - public int contact; - - [InputControl(layout = "Vector2")] - public int position; - - [InputControl(layout = "Vector2")] - public int tilt; - - [InputControl(layout = "Vector2")] - public int radius; - - [InputControl(layout = "Axis")] - public int pressure; - - [InputControl(layout = "Axis")] - public int twist; - - [InputControl(layout = "Integer")] - public int inputId; - - public override PointerInput ReadValue(ref InputBindingCompositeContext context) - { - var contact = context.ReadValueAsButton(this.contact); - var pointerId = context.ReadValue(inputId); - var pressure = context.ReadValue(this.pressure); - var radius = context.ReadValue(this.radius); - var tilt = context.ReadValue(this.tilt); - var position = context.ReadValue(this.position); - var twist = context.ReadValue(this.twist); - - return new PointerInput - { - Contact = contact, - InputId = pointerId, - Position = position, - Tilt = tilt != default ? tilt : (Vector2?)null, - Pressure = pressure > 0 ? pressure : (float?)null, - Radius = radius.sqrMagnitude > 0 ? radius : (Vector2?)null, - Twist = twist > 0 ? twist : (float?)null, - }; - } - - #if UNITY_EDITOR - static PointerInputComposite() - { - Register(); - } - - #endif - - [RuntimeInitializeOnLoadMethod] - private static void Register() - { - InputSystem.RegisterBindingComposite(); - } - } -} diff --git a/Runtime/Gestures/PointerInput.cs.meta b/Runtime/Gestures/PointerInput.cs.meta deleted file mode 100644 index fc0fa0f..0000000 --- a/Runtime/Gestures/PointerInput.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 26a1a49bccb1642d8b9241121d9483a4 \ No newline at end of file diff --git a/Runtime/Gestures/PointerInputManager.cs b/Runtime/Gestures/PointerInputManager.cs deleted file mode 100755 index 73d5bdf..0000000 --- a/Runtime/Gestures/PointerInputManager.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using GameLovers.MobileServices.Gestures.Controls; -using UnityEngine; -using UnityEngine.EventSystems; -using UnityEngine.InputSystem; - -namespace GameLovers.MobileServices.Gestures -{ - /// - /// Input manager that interprets pen, mouse and touch input for mostly drag related controls. - /// Passes pressure, tilt, twist and touch radius through to drawing components for processing. - /// - /// - /// Couple notes about the control setup: - /// - /// - Touch is split off from mouse and pen instead of just using `<Pointer>/position` etc. - /// in order to support multi-touch. If we just bind to and - /// such, we will correctly receive the primary touch but the primary touch only. So we put - /// bindings for pen and mouse separate to those from touch. - /// - Mouse and pen are put into one composite. The expectation here is that they are not used - /// independently from another and thus don't need to be represented as separate pointer sources. - /// However, we could just as well have one for mice and - /// one for pens. - /// - is enabled on . - /// The reason is that we want to source arbitrary many pointer inputs through one single actions. - /// Without pass-through, the default conflict resolution on actions would kick in and let only - /// one of the composite bindings through at a time. - /// - public class PointerInputManager : MonoBehaviour - { - /// - /// Event fired when the user presses on the screen. - /// - public event Action Pressed; - - /// - /// Event fired as the user drags along the screen. - /// - public event Action Dragged; - - /// - /// Event fired when the user releases a press. - /// - public event Action Released; - - private bool m_Dragging; - private PointerControls m_Controls; - - // These are useful for debugging, especially when touch simulation is on. - [SerializeField] private bool m_UseMouse; - [SerializeField] private bool m_UsePen; - [SerializeField] private bool m_UseTouch; - - protected virtual void Awake() - { - m_Controls = new PointerControls(); - - m_Controls.pointer.point.performed += OnAction; - // The action isn't likely to actually cancel as we've bound it to all kinds of inputs but we still - // hook this up so in case the entire thing resets, we do get a call. - m_Controls.pointer.point.canceled += OnAction; - - SyncBindingMask(); - } - - protected virtual void OnEnable() - { - m_Controls?.Enable(); - } - - protected virtual void OnDisable() - { - m_Controls?.Disable(); - } - - protected void OnAction(InputAction.CallbackContext context) - { - var control = context.control; - var device = control.device; - - var isMouseInput = device is Mouse; - var isPenInput = !isMouseInput && device is Pen; - - // Read our current pointer values. - var drag = context.ReadValue(); - if (isMouseInput) - drag.InputId = PointerInputModule.kMouseLeftId; - else if (isPenInput) - drag.InputId = int.MinValue; - - if (drag.Contact && !m_Dragging) - { - Pressed?.Invoke(drag, context.time); - m_Dragging = true; - } - else if (drag.Contact && m_Dragging) - { - Dragged?.Invoke(drag, context.time); - } - else - { - Released?.Invoke(drag, context.time); - m_Dragging = false; - } - } - - private void SyncBindingMask() - { - if (m_Controls == null) - return; - - if (m_UseMouse && m_UsePen && m_UseTouch) - { - m_Controls.bindingMask = null; - return; - } - - m_Controls.bindingMask = InputBinding.MaskByGroups(m_UseMouse ? "Mouse" : null, m_UsePen ? "Pen" : null, m_UseTouch ? "Touch" : null); - } - - private void OnValidate() - { - SyncBindingMask(); - } - } -} diff --git a/Runtime/Gestures/PointerInputManager.cs.meta b/Runtime/Gestures/PointerInputManager.cs.meta deleted file mode 100644 index ac19950..0000000 --- a/Runtime/Gestures/PointerInputManager.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2a6f39d5018c24d69817d0c018814a9d \ No newline at end of file diff --git a/Runtime/Gestures/TapInput.cs b/Runtime/Gestures/TapInput.cs new file mode 100644 index 0000000..4c5ae05 --- /dev/null +++ b/Runtime/Gestures/TapInput.cs @@ -0,0 +1,22 @@ +using UnityEngine; + +namespace GameLovers.MobileServices.Gestures +{ + public struct TapInput + { + public readonly Vector2 PressPosition; + public readonly Vector2 ReleasePosition; + public readonly double TapDuration; + public readonly float TapDrift; + public readonly double TimeStamp; + + internal TapInput(ActiveGesture gesture) : this() + { + PressPosition = gesture.StartPosition; + ReleasePosition = gesture.EndPosition; + TapDuration = gesture.EndTime - gesture.StartTime; + TapDrift = gesture.TravelDistance; + TimeStamp = gesture.EndTime; + } + } +} diff --git a/Runtime/Gestures/TapInput.cs.meta b/Runtime/Gestures/TapInput.cs.meta new file mode 100644 index 0000000..8dfc93d --- /dev/null +++ b/Runtime/Gestures/TapInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2df17263f80ff4e4c9c3ad6f8a0ca32c \ No newline at end of file From d72da54ebf57ccfba31dfd926f5c1b5611a4e887 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Thu, 23 Apr 2026 00:29:05 +0300 Subject: [PATCH 05/32] chore: sync Unity-regenerated .meta files Made-with: Cursor --- CHANGELOG.md.meta | 2 +- Plugins/iOS.meta | 2 +- Plugins/iOS/NativeUi.m.meta | 2 +- README.md.meta | 2 +- Runtime.meta | 2 +- Runtime/Notifications/Android.meta | 8 ++++++++ .../Notifications/Android/AndroidGameNotification.cs.meta | 2 +- .../Android/AndroidNotificationsPlatform.cs.meta | 2 +- .../Notifications/Internal/EditorGameNotification.cs.meta | 2 +- .../Internal/IGameNotificationsPlatform.cs.meta | 2 +- .../Internal/SerializableNotification.cs.meta | 2 +- Runtime/Notifications/iOS/iOSGameNotification.cs.meta | 2 +- .../Notifications/iOS/iOSNotificationsPlatform.cs.meta | 2 +- package.json.meta | 2 +- 14 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 Runtime/Notifications/Android.meta 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/Plugins/iOS.meta b/Plugins/iOS.meta index 4a1a031..670f493 100644 --- a/Plugins/iOS.meta +++ b/Plugins/iOS.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 51d04891c7a3041f290bd67d1e602e2b +guid: eb7c761f639284b489d42ce2b3050a69 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Plugins/iOS/NativeUi.m.meta b/Plugins/iOS/NativeUi.m.meta index 1dc462b..8633b4b 100644 --- a/Plugins/iOS/NativeUi.m.meta +++ b/Plugins/iOS/NativeUi.m.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c0d5a9a64318149cc8389caa86d26e6d +guid: f75285768bb8048ce83a504d68d9ea72 PluginImporter: externalObjects: {} serializedVersion: 2 diff --git a/README.md.meta b/README.md.meta index 5f242cd..8254606 100644 --- a/README.md.meta +++ b/README.md.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 51c2c6a6bb928472188465c9f23bda7c +guid: 2df90284a1eff40fb9bf2b61178458d0 TextScriptImporter: externalObjects: {} userData: diff --git a/Runtime.meta b/Runtime.meta index 524e918..4cfff35 100644 --- a/Runtime.meta +++ b/Runtime.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: dce1f8fa827854da59dbe9b7a73d289d +guid: 86bd86435bbf640daa1e69e1aa35330b folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Runtime/Notifications/Android.meta b/Runtime/Notifications/Android.meta new file mode 100644 index 0000000..97dba0d --- /dev/null +++ b/Runtime/Notifications/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3502381b381e449c4b4a7706e4bcc0da +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Notifications/Android/AndroidGameNotification.cs.meta b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta index 59db846..c7b9d1f 100644 --- a/Runtime/Notifications/Android/AndroidGameNotification.cs.meta +++ b/Runtime/Notifications/Android/AndroidGameNotification.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4fa06f4e8ca11453bb1c9f19541d3f4b +guid: 4bed07ce117594a94a3ab7596e22ea2b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta index e4bf190..d8d7cc0 100644 --- a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta +++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 76f440eeba915452d8015ca583f8bdcd +guid: 56d5130c0d2f644b1ae6b3ddce2ce6a4 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs.meta b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta index a5f0b91..71736f8 100644 --- a/Runtime/Notifications/Internal/EditorGameNotification.cs.meta +++ b/Runtime/Notifications/Internal/EditorGameNotification.cs.meta @@ -1,3 +1,3 @@ fileFormatVersion: 2 -guid: dec8fb3d7dc54e56b11fd43b44ea0aac +guid: a052ebf6dc5694df6997f5146b7c227c timeCreated: 1599154756 \ No newline at end of file diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta index a7a67b2..fd4dda9 100644 --- a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta +++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 61be2413974834a019a5d8c5b37dde4e +guid: 3b3bb06dceeff43eeb1e02497cbca219 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs.meta b/Runtime/Notifications/Internal/SerializableNotification.cs.meta index bb1bbea..32b179e 100644 --- a/Runtime/Notifications/Internal/SerializableNotification.cs.meta +++ b/Runtime/Notifications/Internal/SerializableNotification.cs.meta @@ -1,3 +1,3 @@ fileFormatVersion: 2 -guid: fe50232782284ad7a604b234c205ff63 +guid: 9feafbfcc431549358ef3bc9074a3f0c timeCreated: 1596468382 \ No newline at end of file diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs.meta b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta index aed2281..da77613 100644 --- a/Runtime/Notifications/iOS/iOSGameNotification.cs.meta +++ b/Runtime/Notifications/iOS/iOSGameNotification.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 5ead874ace75948419f27b714f72f6c2 +guid: 8e8a948020e47491bba0b2fa0317f370 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta index 8b2cd6d..f2ff17a 100644 --- a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta +++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c1a93181c78954935999e28b0c6a7893 +guid: 49a427f0b194b41b3a9623b6190f3a65 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/package.json.meta b/package.json.meta index 666331e..2d4779b 100644 --- a/package.json.meta +++ b/package.json.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ff1d659c65c69468894701f784996f6c +guid: afbe3ed05e3ed42a0840e4df73fcd80b PackageManifestImporter: externalObjects: {} userData: From 8086853f6594244bdc4c49e18137086b014d048b Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Thu, 23 Apr 2026 02:15:25 +0300 Subject: [PATCH 06/32] docs: add CLAUDE.md wrapper; align README with EnhancedTouch source - Add CLAUDE.md + .meta at package root; thin @AGENTS.md import wrapper - AGENTS.md: add Companion-files blockquote - README.md: remove obsolete PointerInputManager / Controls / "Input System Integration" marketing (source uses EnhancedTouch only); document Tap gestures alongside Swipe (TapInput struct, Tapped event); trim to Option B shape (470 -> 210 lines); add Related docs footer Made-with: Cursor --- AGENTS.md | 2 + CLAUDE.md | 12 ++ CLAUDE.md.meta | 7 + README.md | 430 ++++++++++--------------------------------------- 4 files changed, 106 insertions(+), 345 deletions(-) create mode 100644 CLAUDE.md create mode 100644 CLAUDE.md.meta diff --git a/AGENTS.md b/AGENTS.md index 777e39b..907f6c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # 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+ 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/README.md b/README.md index f3ed5c3..25ce453 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/version-1.0.0-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/github/v/tag/CoderGamester/com.gamelovers.mobileservices?label=version)](CHANGELOG.md) -> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-documentation) | [Contributing](#contributing) +> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Contributing](#contributing) ## Why Use This Package? @@ -14,58 +14,37 @@ Building mobile-specific features in Unity often requires dealing with platform- |---------|----------| | **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, and review prompts with one API | | **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management | -| **Custom gesture detection** | Gesture controller provides swipe detection with velocity, direction, and consistency metrics | -| **Input System boilerplate** | Pointer input manager abstracts touch/mouse input across platforms | +| **Custom gesture detection** | Gesture controller provides swipe and tap detection via Unity's EnhancedTouch | | **Editor testing challenges** | Editor fallbacks for all features enable testing without device builds | -**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Clean platform abstractions. Tested in real mobile games. - -### Key Features - -- **🎭 Native UI Service** - Call native OS dialogs, action sheets, and toasts without platform-specific code -- **📨 Notification Service** - Schedule, cancel, and manage local/remote notifications with channel support -- **👆 Gesture Controller** - Robust swipe detection with velocity, direction, and consistency metrics -- **📱 Platform Optimized** - Built specifically for iOS and Android with editor fallbacks -- **🔧 Input System Integration** - Modern pointer input abstraction using Unity Input System +**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Tested in real mobile games. --- ## System Requirements - **[Unity](https://unity.com/download)** 6000.0+ (Unity 6) -- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) - Automatically resolved -- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) - Automatically resolved - -### Compatibility Matrix +- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) — automatically resolved +- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) — automatically resolved -| Unity Version | Status | Notes | -|---------------|--------|-------| -| 6000.0+ (Unity 6) | ✅ Fully Tested | Primary development target | -| 2022.3 LTS | ⚠️ Untested | May require minor adaptations | - -| Platform | Status | Notes | -|----------|--------|-------| -| iOS | ✅ Supported | Full feature support | -| Android | ✅ Supported | Full feature support | -| Editor | ✅ Supported | Fallbacks for testing | -| Standalone | ⚠️ Limited | Gestures only; no native UI/notifications | -| WebGL | ❌ Not Supported | Mobile-only features | +| Platform | Status | +|---|---| +| iOS | ✅ Supported | +| Android | ✅ Supported | +| Editor | ✅ Supported (fallbacks) | +| Standalone | ⚠️ Gestures only; no native UI/notifications | +| WebGL | ❌ Not Supported | ## Installation ### Via Unity Package Manager (Recommended) 1. Open Unity Package Manager (`Window` → `Package Manager`) -2. Click the `+` button and select `Add package from git URL` -3. Enter the following URL: - ``` - https://github.com/CoderGamester/com.gamelovers.mobileservices.git - ``` +2. Click `+` → `Add package from git URL` +3. Enter: `https://github.com/CoderGamester/com.gamelovers.mobileservices.git` ### Via manifest.json -Add the following line to your project's `Packages/manifest.json`: - ```json { "dependencies": { @@ -76,395 +55,156 @@ Add the following line to your project's `Packages/manifest.json`: --- -## Package Structure - -``` -Runtime/ -├── NativeUi/ -│ └── NativeUiService.cs # Static native UI bridge (alerts, toasts) -├── Notifications/ -│ ├── MobileNotificationService.cs # Main notification manager -│ ├── IGameNotification.cs # Notification abstraction -│ ├── GameNotificationChannel.cs # Channel configuration -│ ├── PendingNotification.cs # Scheduled notification wrapper -│ ├── Android/ # Android-specific implementation -│ ├── iOS/ # iOS-specific implementation -│ └── Internal/ # Platform abstraction internals -└── Gestures/ - ├── GestureController.cs # MonoBehaviour for gesture detection - ├── SwipeInput.cs # Swipe data structure - ├── ActiveGesture.cs # Gesture state tracking - ├── PointerInputManager.cs # Input System abstraction - └── Controls/ # Input action definitions - -Plugins/ -└── iOS/ - └── NativeUi.m # Objective-C native bridge -``` - -### Key Components +## Key Components | Component | Responsibility | |-----------|----------------| | **NativeUiService** | Static class bridging native iOS/Android UI (alerts, action sheets, toasts) | | **MobileNotificationService** | Notification scheduling, cancellation, and channel management | | **IGameNotification** | Platform-agnostic notification interface | -| **GestureController** | MonoBehaviour detecting swipe gestures with configurable thresholds | +| **GestureController** | MonoBehaviour detecting swipe and tap gestures via EnhancedTouch | | **SwipeInput** | Data structure with swipe direction, velocity, and consistency metrics | -| **PointerInputManager** | Input System wrapper for touch/mouse pointer abstraction | +| **TapInput** | Data structure for tap position and finger data | --- ## Quick Start -### 1. Native UI +### Native UI ```csharp using GameLovers.MobileServices.NativeUi; -// Show a simple alert NativeUiService.ShowAlertPopUp( - darkMode: false, - title: "Welcome", - message: "Thank you for playing!", - new AlertButton { Text = "OK", Style = AlertButtonStyle.Default } -); - -// Show an alert with multiple buttons -NativeUiService.ShowAlertPopUp( - darkMode: true, + darkMode: false, title: "Delete Save?", message: "This action cannot be undone.", new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, OnClick = OnDeleteConfirmed } ); -// Show a toast message (Android only) -NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); - -// Request app store review +NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); // Android only NativeUiService.RequestReview(); ``` ---- - -### 2. Notifications +### Notifications ```csharp using GameLovers.MobileServices.Notifications; -public class NotificationManager : MonoBehaviour -{ - private MobileNotificationService _notificationService; - - void Awake() - { - // Initialize with notification channels - _notificationService = new MobileNotificationService( - new GameNotificationChannel("default", "Default", "Default notifications"), - new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") - ); - } - - public void ScheduleDailyReward() - { - // Create and configure notification - var notification = _notificationService.CreateNotification(); - notification.Title = "Daily Reward Ready!"; - notification.Body = "Your daily reward is waiting for you!"; - notification.DeliveryTime = DateTime.Now.AddHours(24); - notification.SmallIcon = "icon_reward"; - notification.Channel = "rewards"; - - // Schedule it - _notificationService.ScheduleNotification(notification); - } - - public void CancelAllNotifications() - { - _notificationService.CancelAllNotifications(); - } - - void OnApplicationPause(bool pauseStatus) - { - if (pauseStatus) - { - // Schedule reminder when app is backgrounded - ScheduleDailyReward(); - } - } -} -``` +var service = new MobileNotificationService( + new GameNotificationChannel("default", "Default", "Default notifications"), + new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") +); ---- +var notification = service.CreateNotification(); +notification.Title = "Daily Reward Ready!"; +notification.Body = "Your daily reward is waiting for you!"; +notification.DeliveryTime = DateTime.Now.AddHours(24); +notification.Channel = "rewards"; +service.ScheduleNotification(notification); +``` -### 3. Gesture Detection +### Gesture Detection ```csharp -using UnityEngine; using GameLovers.MobileServices.Gestures; -public class SwipeHandler : MonoBehaviour +// Attach GestureController MonoBehaviour to a scene GameObject +// Note: uses Unity's EnhancedTouch API; in Editor add a TouchSimulation component for mouse input + +_gestureController.Swiped += swipe => { - [SerializeField] private GestureController _gestureController; - - void OnEnable() - { - _gestureController.Swiped += OnSwipe; - } - - void OnDisable() - { - _gestureController.Swiped -= OnSwipe; - } - - private void OnSwipe(SwipeInput swipe) - { - Debug.Log($"Swiped {swipe.SwipeDirection}"); - Debug.Log($"Velocity: {swipe.SwipeVelocity}"); - Debug.Log($"Sameness: {swipe.SwipeSameness}"); // Direction consistency (0-1) - - switch (swipe.SwipeDirection) - { - case SwipeDirection.Left: - // Handle left swipe - break; - case SwipeDirection.Right: - // Handle right swipe - break; - case SwipeDirection.Up: - // Handle up swipe - break; - case SwipeDirection.Down: - // Handle down swipe - break; - } - } -} + // swipe.SwipeDirection — Up / Down / Left / Right + // swipe.SwipeVelocity — speed of the swipe + // swipe.SwipeSameness — direction consistency 0–1 (higher = cleaner) + if (swipe.SwipeSameness > 0.8f) + ProcessSwipe(swipe.SwipeDirection); +}; + +_gestureController.Tapped += tap => +{ + // tap.Position — screen position of the tap + Debug.Log($"Tapped at {tap.Position}"); +}; ``` --- -## Services Documentation - -### Native UI Service - -Static service bridging native iOS and Android UI components. - -**Key Points:** -- All methods are **static** - no initialization required -- Uses `AndroidJavaClass` for Android, `[DllImport]` for iOS -- Editor provides fallback implementations for testing - -```csharp -using GameLovers.MobileServices.NativeUi; - -// Alert popup with callback -NativeUiService.ShowAlertPopUp( - darkMode: false, - title: "Confirm Purchase", - message: "Buy 100 gems for $0.99?", - new AlertButton - { - Text = "Cancel", - Style = AlertButtonStyle.Cancel - }, - new AlertButton - { - Text = "Buy", - Style = AlertButtonStyle.Default, - OnClick = () => ProcessPurchase() - } -); +## Services at a Glance -// Toast (Android only, no-op on iOS) -NativeUiService.ShowToastMessage("Saved!", isLongDuration: false); +### Native UI -// App Store / Play Store review prompt -NativeUiService.RequestReview(); -``` +All methods are **static** — no initialization needed. The service is platform-gated: no-op in the Editor (logs only), throws on unsupported platforms. -**Alert Button Styles:** -- `Default` - Standard button appearance -- `Cancel` - Cancel/dismiss style -- `Destructive` - Red/warning style for destructive actions +| Method | Platform | +|--------|----------| +| `ShowAlertPopUp(darkMode, title, message, buttons…)` | iOS + Android | +| `ShowToastMessage(message, isLongDuration)` | Android only | +| `RequestReview()` | iOS (`SKStoreReviewController`) + Android (Play In-App Review) | ---- +**Alert Button Styles:** `Default`, `Cancel`, `Destructive` ### Notification Service -Wrapper around Unity Mobile Notifications with simplified channel and scheduling API. - -**Key Points:** -- Requires channel configuration at initialization -- Creates a `DontDestroyOnLoad` host GameObject (`GameNotificationsMonoBehaviour`) -- Supports platform-specific notification features via `IGameNotification` - ```csharp -using GameLovers.MobileServices.Notifications; - -// Initialize with channels -var service = new MobileNotificationService( - new GameNotificationChannel("general", "General", "General notifications"), - new GameNotificationChannel("promo", "Promotions", "Promotional offers") -); - -// Create notification -var notification = service.CreateNotification(); -notification.Title = "Special Offer!"; -notification.Body = "50% off all items today only!"; -notification.DeliveryTime = DateTime.Now.AddMinutes(30); -notification.Channel = "promo"; -notification.BadgeNumber = 1; - -// Schedule -var pending = service.ScheduleNotification(notification); - -// Cancel specific notification service.CancelNotification(pending.Id); - -// Cancel all service.CancelAllNotifications(); - -// Get pending notifications var scheduled = service.GetPendingNotifications(); ``` -**IGameNotification Properties:** -| Property | Description | -|----------|-------------| -| `Title` | Notification title text | -| `Body` | Notification body text | -| `DeliveryTime` | When to deliver (DateTime) | -| `Channel` | Channel ID for grouping | -| `SmallIcon` | Icon resource name | -| `LargeIcon` | Large icon resource name | -| `BadgeNumber` | App badge count | - ---- +Key points: +- Android requires at least one channel; the first passed becomes the default. +- Creates a `DontDestroyOnLoad` host GameObject — teardown explicitly in tests or game reset flows. +- `OperatingMode.Queue*` defers scheduling to the OS until the app backgrounds. ### Gesture Controller -MonoBehaviour for detecting swipe gestures with configurable sensitivity. - -**Key Points:** -- Attach to a GameObject in your scene -- Raises `Swiped` event with detailed swipe data -- Configurable via inspector or code - -```csharp -using UnityEngine; -using GameLovers.MobileServices.Gestures; +Key points: +- Powered by Unity's `EnhancedTouch` API — `EnhancedTouchSupport` is enabled/disabled automatically in `OnEnable`/`OnDisable`. +- For mouse input in Editor: add a `TouchSimulation` component. +- If `minSwipeDistance <= maxTapDrift`, an interaction may qualify as both tap and swipe — tune thresholds carefully. -public class CardSwiper : MonoBehaviour -{ - [SerializeField] private GestureController _gestureController; - - void Start() - { - _gestureController.Swiped += HandleSwipe; - } - - void OnDestroy() - { - _gestureController.Swiped -= HandleSwipe; - } - - private void HandleSwipe(SwipeInput swipe) - { - // swipe.SwipeDirection - Up, Down, Left, Right - // swipe.SwipeVelocity - Speed of the swipe - // swipe.SwipeSameness - How consistent the direction was (0-1) - // swipe.StartPosition - Where the swipe started - // swipe.EndPosition - Where the swipe ended - - if (swipe.SwipeSameness > 0.8f) // Clean, intentional swipe - { - ProcessSwipe(swipe.SwipeDirection); - } - } -} -``` +**SwipeInput fields:** -**SwipeInput Fields:** | Field | Type | Description | -|-------|------|-------------| -| `SwipeDirection` | `SwipeDirection` | Detected direction (Up/Down/Left/Right) | -| `SwipeVelocity` | `float` | Speed of the swipe gesture | -| `SwipeSameness` | `float` | Direction consistency (0-1, higher = cleaner swipe) | -| `StartPosition` | `Vector2` | Screen position where swipe started | -| `EndPosition` | `Vector2` | Screen position where swipe ended | +|---|---|---| +| `SwipeDirection` | `SwipeDirection` | Up / Down / Left / Right | +| `SwipeVelocity` | `float` | Speed of the gesture | +| `SwipeSameness` | `float` | Direction consistency 0–1 | +| `StartPosition` | `Vector2` | Screen start position | +| `EndPosition` | `Vector2` | Screen end position | --- ## Platform-Specific Notes -### iOS +**iOS:** Native UI via Objective-C bridge (`Plugins/iOS/NativeUi.m`). Alert callbacks matched by button text — keep button texts unique per alert. -- Native UI uses Objective-C bridge (`Plugins/iOS/NativeUi.m`) -- Notifications require iOS notification permissions -- Review prompts use `SKStoreReviewController` +**Android:** Native UI via `AndroidJavaClass` reflection. Notifications require channels (Android 8.0+). -### Android - -- Native UI uses `AndroidJavaClass` reflection -- Toast messages use native Android Toast API -- Notifications support notification channels (Android 8.0+) -- Review prompts use Play In-App Review API - -### Editor - -- Alert popups log to console (or use Unity dialog if available) -- Toast messages log to console -- Notifications are simulated (logged but not scheduled) -- Gestures work with mouse input +**Editor:** Alerts and toasts log to console. Notifications are logged but not scheduled. Gestures work via `TouchSimulation`. --- ## Contributing -We welcome contributions! Here's how you can help: - -### Reporting Issues +Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). -- Use the [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) page -- Include Unity version, package version, and reproduction steps -- Specify target platform (iOS/Android) and device info -- Attach relevant code samples, error logs, or screenshots - -### Development Setup - -1. Fork the repository on GitHub -2. Clone your fork: `git clone https://github.com/yourusername/com.gamelovers.mobileservices.git` -3. Create a feature branch: `git checkout -b feature/amazing-feature` -4. Make your changes with tests -5. Commit: `git commit -m 'Add amazing feature'` -6. Push: `git push origin feature/amazing-feature` -7. Create a Pull Request - -### Code Guidelines +--- -- Follow C# 9.0 syntax with explicit namespaces (no global usings) -- Add XML documentation to all public APIs -- Use platform defines: `#if UNITY_IOS`, `#if UNITY_ANDROID`, `#if UNITY_EDITOR` -- Include unit tests for new features -- Runtime code must not reference `UnityEditor` -- Update CHANGELOG.md for notable changes +## Related docs ---- +| Document | Purpose | +|---|---| +| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | +| [CHANGELOG.md](CHANGELOG.md) | Version history | ## Support - **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) - **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/com.gamelovers.mobileservices/discussions) -- **Changelog**: See [CHANGELOG.md](CHANGELOG.md) for version history ## License -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. - ---- - -**Made with ❤️ for the Unity community** - -*If this package helps your project, please consider giving it a ⭐ on GitHub!* +MIT — see [LICENSE.md](LICENSE.md). From 6d8ad44b283710077c2b9cc72e513333a5166284 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 4 May 2026 17:14:51 +0300 Subject: [PATCH 07/32] chore: gitignore .audit-history.md (preventive) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From fbc57aa4fe4239119f94f56e50111e7b32dcebc6 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 4 May 2026 22:56:21 +0300 Subject: [PATCH 08/32] =?UTF-8?q?feat:=20v1.0.0=20=E2=80=94=20Device=20sub?= =?UTF-8?q?system,=20Haptics,=20audio=20session,=20full=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IDeviceService umbrella facade with 8 sub-services: SafeArea, ScreenWake, Battery (with iOS/Android low-power-mode awareness), Connectivity, IosAudioSession, Permissions (unified iOS+Android, Task-based async), App Tracking Transparency (zero ads-ios-support dep), Deep Link (with cold-start link queueing). One shared internal MonoBehaviour host (DeviceServicesHost) for all event-driven children. - Add IHapticsService with 9 cross-platform presets, custom intensity, and time-bounded looping. iOS UI*FeedbackGenerator + Android VibrationEffect bridges, no third-party plugin dependency. - Add NativeUiService.RequestReview() (iOS SKStoreReviewController + Android Play Core In-App Review) and NativeUiService.Share() (iOS UIActivityViewController + Android Intent.ACTION_SEND). - Add 5 iOS native bridges (Att.m, Battery.m, Haptics.m, iOSAudioSession.m, Permissions.m) under Plugins/iOS/. - Add Runtime/AssemblyInfo.cs granting InternalsVisibleTo to both test assemblies for black-box-internal access. - Bootstrap NUnit test suite: 28 fixtures, 106 active tests (75 EditMode + 31 PlayMode), all green. Codify black-box-only testing policy in a new Tests/AGENTS.md (fourth per-package tests guide in the repo, after services/uiservice/gamedata). - Rewrite README/AGENTS/CHANGELOG to document the v1.0.0 surface and gotchas. Co-authored-by: Cursor --- AGENTS.md | 105 ++-- CHANGELOG.md | 118 +++- Plugins/iOS/Att.m | 53 ++ Plugins/iOS/Att.m.meta | 2 + Plugins/iOS/Battery.m | 39 ++ Plugins/iOS/Battery.m.meta | 2 + Plugins/iOS/Haptics.m | 195 +++++++ Plugins/iOS/Haptics.m.meta | 2 + Plugins/iOS/NativeUi.m | 102 +++- Plugins/iOS/Permissions.m | 267 +++++++++ Plugins/iOS/Permissions.m.meta | 2 + Plugins/iOS/iOSAudioSession.m | 21 + Plugins/iOS/iOSAudioSession.m.meta | 2 + README.md | 516 +++++++++++------- Runtime/AssemblyInfo.cs | 4 + Runtime/AssemblyInfo.cs.meta | 2 + Runtime/Device.meta | 8 + Runtime/Device/Audio.meta | 8 + .../Device/Audio/IIosAudioSessionService.cs | 21 + .../Audio/IIosAudioSessionService.cs.meta | 2 + .../Device/Audio/IosAudioSessionService.cs | 34 ++ .../Audio/IosAudioSessionService.cs.meta | 2 + Runtime/Device/DeepLinks.meta | 8 + Runtime/Device/DeepLinks/DeepLinkService.cs | 74 +++ .../Device/DeepLinks/DeepLinkService.cs.meta | 2 + Runtime/Device/DeepLinks/IDeepLinkService.cs | 25 + .../Device/DeepLinks/IDeepLinkService.cs.meta | 2 + Runtime/Device/DeviceService.cs | 91 +++ Runtime/Device/DeviceService.cs.meta | 2 + Runtime/Device/IDeviceService.cs | 35 ++ Runtime/Device/IDeviceService.cs.meta | 2 + Runtime/Device/Internal.meta | 8 + Runtime/Device/Internal/DeviceServicesHost.cs | 152 ++++++ .../Internal/DeviceServicesHost.cs.meta | 2 + Runtime/Device/Permissions.meta | 8 + .../Device/Permissions/IPermissionsService.cs | 43 ++ .../Permissions/IPermissionsService.cs.meta | 2 + Runtime/Device/Permissions/Internal.meta | 8 + .../Internal/PermissionsCallbackReceiver.cs | 96 ++++ .../PermissionsCallbackReceiver.cs.meta | 2 + .../Device/Permissions/PermissionsService.cs | 105 ++++ .../Permissions/PermissionsService.cs.meta | 2 + Runtime/Device/State.meta | 8 + Runtime/Device/State/BatteryService.cs | 149 +++++ Runtime/Device/State/BatteryService.cs.meta | 2 + Runtime/Device/State/ConnectivityService.cs | 62 +++ .../Device/State/ConnectivityService.cs.meta | 2 + Runtime/Device/State/IBatteryService.cs | 31 ++ Runtime/Device/State/IBatteryService.cs.meta | 2 + Runtime/Device/State/IConnectivityService.cs | 20 + .../Device/State/IConnectivityService.cs.meta | 2 + Runtime/Device/State/ISafeAreaService.cs | 19 + Runtime/Device/State/ISafeAreaService.cs.meta | 2 + Runtime/Device/State/IScreenWakeService.cs | 15 + .../Device/State/IScreenWakeService.cs.meta | 2 + Runtime/Device/State/SafeAreaContainer.cs | 77 +++ .../Device/State/SafeAreaContainer.cs.meta | 2 + Runtime/Device/State/SafeAreaService.cs | 58 ++ Runtime/Device/State/SafeAreaService.cs.meta | 2 + Runtime/Device/State/ScreenWakeService.cs | 16 + .../Device/State/ScreenWakeService.cs.meta | 2 + Runtime/Device/Tracking.meta | 8 + Runtime/Device/Tracking/AttService.cs | 129 +++++ Runtime/Device/Tracking/AttService.cs.meta | 2 + Runtime/Device/Tracking/IAttService.cs | 34 ++ Runtime/Device/Tracking/IAttService.cs.meta | 2 + Runtime/Haptics.meta | 8 + Runtime/Haptics/HapticPreset.cs | 40 ++ Runtime/Haptics/HapticPreset.cs.meta | 2 + Runtime/Haptics/HapticsService.cs | 155 ++++++ Runtime/Haptics/HapticsService.cs.meta | 2 + Runtime/Haptics/IHapticsService.cs | 60 ++ Runtime/Haptics/IHapticsService.cs.meta | 2 + Runtime/Haptics/Internal.meta | 8 + .../Haptics/Internal/AndroidHapticsBackend.cs | 211 +++++++ .../Internal/AndroidHapticsBackend.cs.meta | 2 + .../Haptics/Internal/EditorHapticsBackend.cs | 40 ++ .../Internal/EditorHapticsBackend.cs.meta | 2 + Runtime/Haptics/Internal/HapticsHost.cs | 50 ++ Runtime/Haptics/Internal/HapticsHost.cs.meta | 2 + Runtime/Haptics/Internal/IHapticsBackend.cs | 25 + .../Haptics/Internal/IHapticsBackend.cs.meta | 2 + Runtime/Haptics/Internal/IosHapticsBackend.cs | 75 +++ .../Internal/IosHapticsBackend.cs.meta | 2 + .../Haptics/Internal/NoOpHapticsBackend.cs | 25 + .../Internal/NoOpHapticsBackend.cs.meta | 2 + Runtime/NativeUi/NativeUiService.cs | 155 +++++- Tests.meta | 8 + Tests/AGENTS.md | 93 ++++ Tests/AGENTS.md.meta | 7 + Tests/EditMode.meta | 8 + ...overs.MobileServices.EditMode.Tests.asmdef | 26 + ....MobileServices.EditMode.Tests.asmdef.meta | 7 + Tests/EditMode/Unit.meta | 8 + Tests/EditMode/Unit/ActiveGestureTest.cs | 78 +++ Tests/EditMode/Unit/ActiveGestureTest.cs.meta | 2 + Tests/EditMode/Unit/AttServiceTest.cs | 35 ++ Tests/EditMode/Unit/AttServiceTest.cs.meta | 2 + Tests/EditMode/Unit/DeepLinkServiceTest.cs | 53 ++ .../EditMode/Unit/DeepLinkServiceTest.cs.meta | 2 + Tests/EditMode/Unit/DeviceServiceTest.cs | 109 ++++ Tests/EditMode/Unit/DeviceServiceTest.cs.meta | 2 + .../Unit/EditorGameNotificationTest.cs | 50 ++ .../Unit/EditorGameNotificationTest.cs.meta | 2 + .../EditMode/Unit/EditorHapticsBackendTest.cs | 56 ++ .../Unit/EditorHapticsBackendTest.cs.meta | 2 + .../Unit/GameNotificationChannelTest.cs | 88 +++ .../Unit/GameNotificationChannelTest.cs.meta | 2 + Tests/EditMode/Unit/HapticPresetTest.cs | 17 + Tests/EditMode/Unit/HapticPresetTest.cs.meta | 2 + Tests/EditMode/Unit/HapticsServiceTest.cs | 205 +++++++ .../EditMode/Unit/HapticsServiceTest.cs.meta | 2 + .../Unit/IosAudioSessionServiceTest.cs | 24 + .../Unit/IosAudioSessionServiceTest.cs.meta | 2 + Tests/EditMode/Unit/NativeUiServiceTest.cs | 77 +++ .../EditMode/Unit/NativeUiServiceTest.cs.meta | 2 + Tests/EditMode/Unit/NoOpHapticsBackendTest.cs | 24 + .../Unit/NoOpHapticsBackendTest.cs.meta | 2 + Tests/EditMode/Unit/OperatingModeTest.cs | 31 ++ Tests/EditMode/Unit/OperatingModeTest.cs.meta | 2 + .../EditMode/Unit/PendingNotificationTest.cs | 36 ++ .../Unit/PendingNotificationTest.cs.meta | 2 + Tests/EditMode/Unit/PermissionsServiceTest.cs | 49 ++ .../Unit/PermissionsServiceTest.cs.meta | 2 + Tests/EditMode/Unit/SafeAreaContainerTest.cs | 91 +++ .../Unit/SafeAreaContainerTest.cs.meta | 2 + Tests/EditMode/Unit/ScreenWakeServiceTest.cs | 55 ++ .../Unit/ScreenWakeServiceTest.cs.meta | 2 + Tests/EditMode/Unit/SwipeInputTest.cs | 50 ++ Tests/EditMode/Unit/SwipeInputTest.cs.meta | 2 + Tests/EditMode/Unit/TapInputTest.cs | 28 + Tests/EditMode/Unit/TapInputTest.cs.meta | 2 + Tests/PlayMode.meta | 8 + ...overs.MobileServices.PlayMode.Tests.asmdef | 23 + ....MobileServices.PlayMode.Tests.asmdef.meta | 7 + Tests/PlayMode/Smoke.meta | 8 + .../Smoke/GestureControllerSmokeTest.cs | 73 +++ .../Smoke/GestureControllerSmokeTest.cs.meta | 2 + Tests/PlayMode/Unit.meta | 8 + .../PlayMode/Unit/AttCallbackReceiverTest.cs | 56 ++ .../Unit/AttCallbackReceiverTest.cs.meta | 2 + Tests/PlayMode/Unit/BatteryServiceTest.cs | 43 ++ .../PlayMode/Unit/BatteryServiceTest.cs.meta | 2 + .../PlayMode/Unit/ConnectivityServiceTest.cs | 40 ++ .../Unit/ConnectivityServiceTest.cs.meta | 2 + Tests/PlayMode/Unit/DeviceServicesHostTest.cs | 111 ++++ .../Unit/DeviceServicesHostTest.cs.meta | 2 + .../Unit/HapticsServicePlayModeTest.cs | 134 +++++ .../Unit/HapticsServicePlayModeTest.cs.meta | 2 + .../Unit/MobileNotificationServiceTest.cs | 90 +++ .../MobileNotificationServiceTest.cs.meta | 2 + .../Unit/PermissionsCallbackReceiverTest.cs | 67 +++ .../PermissionsCallbackReceiverTest.cs.meta | 2 + Tests/PlayMode/Unit/SafeAreaServiceTest.cs | 62 +++ .../PlayMode/Unit/SafeAreaServiceTest.cs.meta | 2 + 155 files changed, 5505 insertions(+), 288 deletions(-) create mode 100644 Plugins/iOS/Att.m create mode 100644 Plugins/iOS/Att.m.meta create mode 100644 Plugins/iOS/Battery.m create mode 100644 Plugins/iOS/Battery.m.meta create mode 100644 Plugins/iOS/Haptics.m create mode 100644 Plugins/iOS/Haptics.m.meta create mode 100644 Plugins/iOS/Permissions.m create mode 100644 Plugins/iOS/Permissions.m.meta create mode 100644 Plugins/iOS/iOSAudioSession.m create mode 100644 Plugins/iOS/iOSAudioSession.m.meta create mode 100644 Runtime/AssemblyInfo.cs create mode 100644 Runtime/AssemblyInfo.cs.meta create mode 100644 Runtime/Device.meta create mode 100644 Runtime/Device/Audio.meta create mode 100644 Runtime/Device/Audio/IIosAudioSessionService.cs create mode 100644 Runtime/Device/Audio/IIosAudioSessionService.cs.meta create mode 100644 Runtime/Device/Audio/IosAudioSessionService.cs create mode 100644 Runtime/Device/Audio/IosAudioSessionService.cs.meta create mode 100644 Runtime/Device/DeepLinks.meta create mode 100644 Runtime/Device/DeepLinks/DeepLinkService.cs create mode 100644 Runtime/Device/DeepLinks/DeepLinkService.cs.meta create mode 100644 Runtime/Device/DeepLinks/IDeepLinkService.cs create mode 100644 Runtime/Device/DeepLinks/IDeepLinkService.cs.meta create mode 100644 Runtime/Device/DeviceService.cs create mode 100644 Runtime/Device/DeviceService.cs.meta create mode 100644 Runtime/Device/IDeviceService.cs create mode 100644 Runtime/Device/IDeviceService.cs.meta create mode 100644 Runtime/Device/Internal.meta create mode 100644 Runtime/Device/Internal/DeviceServicesHost.cs create mode 100644 Runtime/Device/Internal/DeviceServicesHost.cs.meta create mode 100644 Runtime/Device/Permissions.meta create mode 100644 Runtime/Device/Permissions/IPermissionsService.cs create mode 100644 Runtime/Device/Permissions/IPermissionsService.cs.meta create mode 100644 Runtime/Device/Permissions/Internal.meta create mode 100644 Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs create mode 100644 Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta create mode 100644 Runtime/Device/Permissions/PermissionsService.cs create mode 100644 Runtime/Device/Permissions/PermissionsService.cs.meta create mode 100644 Runtime/Device/State.meta create mode 100644 Runtime/Device/State/BatteryService.cs create mode 100644 Runtime/Device/State/BatteryService.cs.meta create mode 100644 Runtime/Device/State/ConnectivityService.cs create mode 100644 Runtime/Device/State/ConnectivityService.cs.meta create mode 100644 Runtime/Device/State/IBatteryService.cs create mode 100644 Runtime/Device/State/IBatteryService.cs.meta create mode 100644 Runtime/Device/State/IConnectivityService.cs create mode 100644 Runtime/Device/State/IConnectivityService.cs.meta create mode 100644 Runtime/Device/State/ISafeAreaService.cs create mode 100644 Runtime/Device/State/ISafeAreaService.cs.meta create mode 100644 Runtime/Device/State/IScreenWakeService.cs create mode 100644 Runtime/Device/State/IScreenWakeService.cs.meta create mode 100644 Runtime/Device/State/SafeAreaContainer.cs create mode 100644 Runtime/Device/State/SafeAreaContainer.cs.meta create mode 100644 Runtime/Device/State/SafeAreaService.cs create mode 100644 Runtime/Device/State/SafeAreaService.cs.meta create mode 100644 Runtime/Device/State/ScreenWakeService.cs create mode 100644 Runtime/Device/State/ScreenWakeService.cs.meta create mode 100644 Runtime/Device/Tracking.meta create mode 100644 Runtime/Device/Tracking/AttService.cs create mode 100644 Runtime/Device/Tracking/AttService.cs.meta create mode 100644 Runtime/Device/Tracking/IAttService.cs create mode 100644 Runtime/Device/Tracking/IAttService.cs.meta create mode 100644 Runtime/Haptics.meta create mode 100644 Runtime/Haptics/HapticPreset.cs create mode 100644 Runtime/Haptics/HapticPreset.cs.meta create mode 100644 Runtime/Haptics/HapticsService.cs create mode 100644 Runtime/Haptics/HapticsService.cs.meta create mode 100644 Runtime/Haptics/IHapticsService.cs create mode 100644 Runtime/Haptics/IHapticsService.cs.meta create mode 100644 Runtime/Haptics/Internal.meta create mode 100644 Runtime/Haptics/Internal/AndroidHapticsBackend.cs create mode 100644 Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta create mode 100644 Runtime/Haptics/Internal/EditorHapticsBackend.cs create mode 100644 Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta create mode 100644 Runtime/Haptics/Internal/HapticsHost.cs create mode 100644 Runtime/Haptics/Internal/HapticsHost.cs.meta create mode 100644 Runtime/Haptics/Internal/IHapticsBackend.cs create mode 100644 Runtime/Haptics/Internal/IHapticsBackend.cs.meta create mode 100644 Runtime/Haptics/Internal/IosHapticsBackend.cs create mode 100644 Runtime/Haptics/Internal/IosHapticsBackend.cs.meta create mode 100644 Runtime/Haptics/Internal/NoOpHapticsBackend.cs create mode 100644 Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/AGENTS.md create mode 100644 Tests/AGENTS.md.meta create mode 100644 Tests/EditMode.meta create mode 100644 Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef create mode 100644 Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta create mode 100644 Tests/EditMode/Unit.meta create mode 100644 Tests/EditMode/Unit/ActiveGestureTest.cs create mode 100644 Tests/EditMode/Unit/ActiveGestureTest.cs.meta create mode 100644 Tests/EditMode/Unit/AttServiceTest.cs create mode 100644 Tests/EditMode/Unit/AttServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/DeepLinkServiceTest.cs create mode 100644 Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/DeviceServiceTest.cs create mode 100644 Tests/EditMode/Unit/DeviceServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/EditorGameNotificationTest.cs create mode 100644 Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta create mode 100644 Tests/EditMode/Unit/EditorHapticsBackendTest.cs create mode 100644 Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta create mode 100644 Tests/EditMode/Unit/GameNotificationChannelTest.cs create mode 100644 Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta create mode 100644 Tests/EditMode/Unit/HapticPresetTest.cs create mode 100644 Tests/EditMode/Unit/HapticPresetTest.cs.meta create mode 100644 Tests/EditMode/Unit/HapticsServiceTest.cs create mode 100644 Tests/EditMode/Unit/HapticsServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/IosAudioSessionServiceTest.cs create mode 100644 Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/NativeUiServiceTest.cs create mode 100644 Tests/EditMode/Unit/NativeUiServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/NoOpHapticsBackendTest.cs create mode 100644 Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta create mode 100644 Tests/EditMode/Unit/OperatingModeTest.cs create mode 100644 Tests/EditMode/Unit/OperatingModeTest.cs.meta create mode 100644 Tests/EditMode/Unit/PendingNotificationTest.cs create mode 100644 Tests/EditMode/Unit/PendingNotificationTest.cs.meta create mode 100644 Tests/EditMode/Unit/PermissionsServiceTest.cs create mode 100644 Tests/EditMode/Unit/PermissionsServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/SafeAreaContainerTest.cs create mode 100644 Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta create mode 100644 Tests/EditMode/Unit/ScreenWakeServiceTest.cs create mode 100644 Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/SwipeInputTest.cs create mode 100644 Tests/EditMode/Unit/SwipeInputTest.cs.meta create mode 100644 Tests/EditMode/Unit/TapInputTest.cs create mode 100644 Tests/EditMode/Unit/TapInputTest.cs.meta create mode 100644 Tests/PlayMode.meta create mode 100644 Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef create mode 100644 Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta create mode 100644 Tests/PlayMode/Smoke.meta create mode 100644 Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs create mode 100644 Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta create mode 100644 Tests/PlayMode/Unit.meta create mode 100644 Tests/PlayMode/Unit/AttCallbackReceiverTest.cs create mode 100644 Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta create mode 100644 Tests/PlayMode/Unit/BatteryServiceTest.cs create mode 100644 Tests/PlayMode/Unit/BatteryServiceTest.cs.meta create mode 100644 Tests/PlayMode/Unit/ConnectivityServiceTest.cs create mode 100644 Tests/PlayMode/Unit/ConnectivityServiceTest.cs.meta create mode 100644 Tests/PlayMode/Unit/DeviceServicesHostTest.cs create mode 100644 Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta create mode 100644 Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs create mode 100644 Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta create mode 100644 Tests/PlayMode/Unit/MobileNotificationServiceTest.cs create mode 100644 Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta create mode 100644 Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs create mode 100644 Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta create mode 100644 Tests/PlayMode/Unit/SafeAreaServiceTest.cs create mode 100644 Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta diff --git a/AGENTS.md b/AGENTS.md index 907f6c3..a0114f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,11 @@ - `com.unity.inputsystem` (**1.11.0**) This package consolidates mobile-specific platform services: -- **Native UI**: alerts (modal + action sheet) and toast-style messages. +- **Native UI**: alerts (modal + action sheet), toast-style messages, OS rating prompt (`RequestReview`), and share sheet (`Share`). - **Notifications**: platform wrapper over Unity Mobile Notifications (Android/iOS). - **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 8 sub-services — `SafeArea`, `ScreenWake`, `Battery` (with iOS / Android low-power-mode awareness), `Connectivity`, `AudioSession` (iOS silent-switch override), `Permissions` (unified iOS+Android, Task-based async), `Att` (App Tracking Transparency, no `com.unity.ads.ios-support` dep), `DeepLink` (with cold-start link queueing). For user-facing docs, treat `README.md` as the primary entry point. This file is for contributors/agents working on the package itself. @@ -20,9 +22,12 @@ For user-facing docs, treat `README.md` as the primary entry point. This file is ### 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` and `android.widget.Toast`. - - iOS: uses `[DllImport("__Internal")]` native functions implemented in `Plugins/iOS/NativeUi.m`. -- **Button model**: `AlertButton` + `AlertButtonStyle`. + - 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` (alerts, toasts, `_GameLoversRequestReview`, `_GameLoversShare`). +- **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. +- **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()` requires `com.google.android.play:review:2.0.1` in the consumer's `mainTemplate.gradle`. Without it the call logs an error and returns; it does not throw. ### Notifications (`GameLovers.MobileServices.Notifications`) - **Public API**: `Runtime/Notifications/MobileNotificationService.cs` @@ -42,6 +47,21 @@ For user-facing docs, treat `README.md` as the primary entry point. This file is - 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. +- **Connectivity**: `Runtime/Device/State/IConnectivityService.cs` + `ConnectivityService.cs`. Polls `Application.internetReachability` once per second + on focus regain; fires `OnStatusChanged` on transition. Documented as best-effort. +- **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. + ### Gestures (`GameLovers.MobileServices.Gestures`) - **Input source**: Unity's `EnhancedTouch` API (`Touch.onFingerDown/Move/Up`) - **Gesture detection** @@ -50,37 +70,32 @@ For user-facing docs, treat `README.md` as the primary entry point. This file is - `Runtime/Gestures/SwipeInput.cs` is the public data structure for swipe output. - `Runtime/Gestures/TapInput.cs` is the public data structure for tap output. -## 3. Key Directories / Files -``` -Runtime/ -├── NativeUi/ -│ └── NativeUiService.cs -├── Notifications/ -│ ├── MobileNotificationService.cs -│ ├── GameNotificationsMonoBehaviour.cs -│ ├── GameNotificationChannel.cs -│ ├── IGameNotification.cs -│ ├── PendingNotification.cs -│ ├── Android/ -│ │ ├── AndroidNotificationsPlatform.cs -│ │ └── AndroidGameNotification.cs -│ ├── iOS/ -│ │ ├── iOSNotificationsPlatform.cs -│ │ └── iOSGameNotification.cs -│ └── Internal/ -│ ├── IGameNotificationsPlatform.cs -│ ├── EditorGameNotification.cs -│ └── SerializableNotification.cs -├── Gestures/ -│ ├── GestureController.cs -│ ├── ActiveGesture.cs -│ ├── SwipeInput.cs -│ └── TapInput.cs -└── GameLovers.MobileServices.asmdef - -Plugins/iOS/ -└── NativeUi.m -``` +### 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. + +## 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")`. +- **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. Tests do NOT mirror the runtime folder structure — group by feature, not by source path. ## 4. Important Behaviors / Gotchas - **NativeUiService is platform-gated** @@ -102,6 +117,20 @@ Plugins/iOS/ - 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. ## 5. Coding Standards (Unity 6 / C# 9.0) - **C#**: C# 9.0 syntax; explicit namespaces; no global usings. @@ -134,7 +163,9 @@ When you need third-party source/docs, prefer the locally-cached UPM packages: ## 8. Update Policy Update this file when: -- Public API changes (`NativeUiService`, `INotificationService`, `IGameNotification`, `GestureController` events) -- Platform integration changes (JNI calls, iOS native symbols, notification platform wrappers) +- Public API changes (`NativeUiService`, `INotificationService`, `IGameNotification`, `GestureController` events, `IHapticsService`, `IDeviceService` and any of its 8 child interfaces) +- 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 `AndroidHapticsBackend` and per-preset routing in `Plugins/iOS/Haptics.m`) +- Permissions catalogue changes (`AppPermission` enum + `AndroidManifestPermission` mapping + iOS `_GameLoversPermissionsRequest` switch) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55da45f..e2f9be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,89 @@ -# 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-01-13 - -### Added -- Initial release of consolidated **Mobile Services** package. -- **Native UI**: Alerts, sheets, and toasts for iOS/Android. -- **Notifications**: Comprehensive local and remote notification management. -- **Gestures**: Advanced swipe detection with velocity and consistency tracking. - -### Changed -- Refactored all namespaces to `GameLovers.MobileServices.*`. -- Updated assembly definition to `GameLovers.MobileServices`. -- Updated dependencies to target Unity 6 (6000.0+). - -### 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` - -### Removed -- Legacy tap detection (replaced by Unity Input System's `TapInteraction`). -- Gamepad input management (out of scope for mobile services). +# 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-05-05 + +### Added +- Initial release of consolidated **Mobile Services** package. +- **Native UI**: Alerts, sheets, and toasts for iOS/Android, plus `NativeUiService.RequestReview()` (iOS `SKStoreReviewController` + Android Play Core In-App Review) and `NativeUiService.Share(text, url, imagePath, title)` (iOS `UIActivityViewController` + Android `Intent.ACTION_SEND`). +- **Notifications**: Comprehensive local and remote notification management. +- **Gestures**: Advanced swipe detection with velocity and consistency tracking. +- **iOS Audio Session**: `IIosAudioSessionService.ConfigureForPlayback()` (also exposed via `device.AudioSession` on the unified `IDeviceService`) overrides the iOS silent switch so audio keeps playing. +- **Haptics**: zero-dependency haptic feedback (`IHapticsService`) with 9 preset patterns, custom intensity, time-bounded looping (`PlayPresetDuration(preset, duration)` with `-1`=loop / `0`=natural one-shot / `>0`=loop with auto-stop), and `StopCurrentHaptic()`. iOS `UI*FeedbackGenerator` + Android `VibrationEffect.createWaveform` bridges, no third-party plugin required. +- **`IDeviceService` umbrella facade** exposing `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, and `DeepLink` sub-services through one entry point. Each child is independently registerable for testing. `IBatteryService` includes low-power-mode awareness on iOS (`NSProcessInfoPowerStateDidChangeNotification`) and Android (`PowerManager.isPowerSaveMode`). `ISafeAreaService` ships with a companion `SafeAreaContainer` UI Toolkit element. All event-driven children share a single internal MonoBehaviour host for polling. +- **Permissions** (`IPermissionsService`, also at `device.Permissions`): unified iOS+Android runtime permissions covering Camera, Microphone, Location (when-in-use & always), Photo Library (read-write & add-only), and Notifications. `Task`-based async — no `UniTask` dependency. +- **App Tracking Transparency** (`IAttService`, also at `device.Att`): iOS 14.5+ `ATTrackingManager` bridge for `RequestAuthorizationAsync()` and `CurrentStatus`. **Zero dependency on the deprecation-bound `com.unity.ads.ios-support` package**. Android / Editor / unsupported platforms return `Authorized` (no equivalent restriction). +- **Deep Links** (`IDeepLinkService`, also at `device.DeepLink`): wraps `Application.deepLinkActivated` and adds **cold-start link queueing** — links delivered by the OS at app launch are not lost if the first subscriber attaches after the event has fired. + +### Changed +- Refactored all namespaces to `GameLovers.MobileServices.*`. +- Updated assembly definition to `GameLovers.MobileServices`. +- Updated dependencies to target Unity 6 (6000.0+). +- Legacy tap detection (replaced by Unity Input System's `TapInteraction`). +- Gamepad input management (out of scope for mobile services), use the new input system configuration for that + +### 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/Plugins/iOS/Att.m b/Plugins/iOS/Att.m new file mode 100644 index 0000000..65a44b5 --- /dev/null +++ b/Plugins/iOS/Att.m @@ -0,0 +1,53 @@ +#import +#import + +extern void UnitySendMessage(const char *gameObject, const char *method, const char *message); + +// Mirrors GameLovers.MobileServices.Device.AttStatus. +typedef NS_ENUM(NSInteger, GLAttStatus) +{ + GLAttStatusNotDetermined = 0, + GLAttStatusRestricted = 1, + GLAttStatusDenied = 2, + GLAttStatusAuthorized = 3 +}; + +static GLAttStatus GLMapAttStatus(ATTrackingManagerAuthorizationStatus s) +{ + switch (s) + { + case ATTrackingManagerAuthorizationStatusNotDetermined: return GLAttStatusNotDetermined; + case ATTrackingManagerAuthorizationStatusRestricted: return GLAttStatusRestricted; + case ATTrackingManagerAuthorizationStatusDenied: return GLAttStatusDenied; + case ATTrackingManagerAuthorizationStatusAuthorized: return GLAttStatusAuthorized; + default: return GLAttStatusNotDetermined; + } +} + +int _GameLoversAttCurrentStatus(void) +{ + if (@available(iOS 14, *)) + { + return GLMapAttStatus([ATTrackingManager trackingAuthorizationStatus]); + } + return GLAttStatusAuthorized; +} + +void _GameLoversAttRequestAuthorization(int requestId, const char *callbackGameObject, const char *callbackMethod) +{ + NSString *goName = [NSString stringWithUTF8String:callbackGameObject]; + NSString *methodName = [NSString stringWithUTF8String:callbackMethod]; + + if (@available(iOS 14, *)) + { + [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) { + NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)GLMapAttStatus(status)]; + UnitySendMessage([goName UTF8String], [methodName UTF8String], [payload UTF8String]); + }]; + } + else + { + NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)GLAttStatusAuthorized]; + UnitySendMessage([goName UTF8String], [methodName UTF8String], [payload UTF8String]); + } +} diff --git a/Plugins/iOS/Att.m.meta b/Plugins/iOS/Att.m.meta new file mode 100644 index 0000000..71bdf82 --- /dev/null +++ b/Plugins/iOS/Att.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f3989467231e4524a5b497a15e96dd5 \ No newline at end of file diff --git a/Plugins/iOS/Battery.m b/Plugins/iOS/Battery.m new file mode 100644 index 0000000..4030170 --- /dev/null +++ b/Plugins/iOS/Battery.m @@ -0,0 +1,39 @@ +#import + +extern void UnitySendMessage(const char *gameObject, const char *method, const char *message); + +static id gLowPowerObserver = nil; + +bool _GameLoversBatteryIsLowPowerModeEnabled(void) +{ + return [[NSProcessInfo processInfo] isLowPowerModeEnabled]; +} + +void _GameLoversBatteryStartObservingLowPowerMode(void) +{ + if (gLowPowerObserver != nil) + { + return; + } + + gLowPowerObserver = [[NSNotificationCenter defaultCenter] + addObserverForName:NSProcessInfoPowerStateDidChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification * _Nonnull note) { + // The shared DeviceServicesHost MonoBehaviour is named "DeviceServicesHost" and + // exposes OnIosLowPowerModeChanged(string) as a public method invokable by SendMessage. + UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", ""); + }]; +} + +void _GameLoversBatteryStopObservingLowPowerMode(void) +{ + if (gLowPowerObserver == nil) + { + return; + } + + [[NSNotificationCenter defaultCenter] removeObserver:gLowPowerObserver]; + gLowPowerObserver = nil; +} diff --git a/Plugins/iOS/Battery.m.meta b/Plugins/iOS/Battery.m.meta new file mode 100644 index 0000000..6a5a889 --- /dev/null +++ b/Plugins/iOS/Battery.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 091645eba84794445bb9a6c86a77967f \ No newline at end of file diff --git a/Plugins/iOS/Haptics.m b/Plugins/iOS/Haptics.m new file mode 100644 index 0000000..17bbe98 --- /dev/null +++ b/Plugins/iOS/Haptics.m @@ -0,0 +1,195 @@ +#import + +// Preset ids must mirror GameLovers.MobileServices.Haptics.HapticPreset enum. +typedef NS_ENUM(NSInteger, GLHapticPresetId) +{ + GLHapticPresetIdNone = 0, + GLHapticPresetIdSelection = 1, + GLHapticPresetIdSuccess = 2, + GLHapticPresetIdWarning = 3, + GLHapticPresetIdError = 4, + GLHapticPresetIdImpactLight = 5, + GLHapticPresetIdImpactMedium = 6, + GLHapticPresetIdImpactHeavy = 7, + GLHapticPresetIdImpactRigid = 8, + GLHapticPresetIdImpactSoft = 9 +}; + +// Reusable generators. Lazily created on first use; reset to nil on Stop so the next call +// gets a freshly-prepared generator (Apple docs: prepare/play within ~100ms for best feel). +static UISelectionFeedbackGenerator *gSelectionGen = nil; +static UINotificationFeedbackGenerator *gNotificationGen = nil; +static UIImpactFeedbackGenerator *gImpactGen = nil; +static NSTimer *gLoopTimer = nil; +static GLHapticPresetId gLoopPresetId = GLHapticPresetIdNone; + +static void GLHapticsPlaySelection(void) +{ + if (gSelectionGen == nil) + { + gSelectionGen = [[UISelectionFeedbackGenerator alloc] init]; + } + [gSelectionGen prepare]; + [gSelectionGen selectionChanged]; +} + +static void GLHapticsPlayNotification(UINotificationFeedbackType type) +{ + if (gNotificationGen == nil) + { + gNotificationGen = [[UINotificationFeedbackGenerator alloc] init]; + } + [gNotificationGen prepare]; + [gNotificationGen notificationOccurred:type]; +} + +static void GLHapticsPlayImpact(UIImpactFeedbackStyle style) +{ + // UIImpactFeedbackGenerator is bound to a single style at init time, so we re-create + // when the requested style changes. Keeping it cached when reused (e.g. looping a single style). + static UIImpactFeedbackStyle sLastStyle = (UIImpactFeedbackStyle)-1; + if (gImpactGen == nil || sLastStyle != style) + { + gImpactGen = [[UIImpactFeedbackGenerator alloc] initWithStyle:style]; + sLastStyle = style; + } + [gImpactGen prepare]; + [gImpactGen impactOccurred]; +} + +static void GLHapticsPlayPresetById(GLHapticPresetId presetId) +{ + switch (presetId) + { + case GLHapticPresetIdNone: + return; + case GLHapticPresetIdSelection: + GLHapticsPlaySelection(); + return; + case GLHapticPresetIdSuccess: + GLHapticsPlayNotification(UINotificationFeedbackTypeSuccess); + return; + case GLHapticPresetIdWarning: + GLHapticsPlayNotification(UINotificationFeedbackTypeWarning); + return; + case GLHapticPresetIdError: + GLHapticsPlayNotification(UINotificationFeedbackTypeError); + return; + case GLHapticPresetIdImpactLight: + GLHapticsPlayImpact(UIImpactFeedbackStyleLight); + return; + case GLHapticPresetIdImpactMedium: + GLHapticsPlayImpact(UIImpactFeedbackStyleMedium); + return; + case GLHapticPresetIdImpactHeavy: + GLHapticsPlayImpact(UIImpactFeedbackStyleHeavy); + return; + case GLHapticPresetIdImpactRigid: + if (@available(iOS 13.0, *)) + { + GLHapticsPlayImpact(UIImpactFeedbackStyleRigid); + } + else + { + GLHapticsPlayImpact(UIImpactFeedbackStyleHeavy); + } + return; + case GLHapticPresetIdImpactSoft: + if (@available(iOS 13.0, *)) + { + GLHapticsPlayImpact(UIImpactFeedbackStyleSoft); + } + else + { + GLHapticsPlayImpact(UIImpactFeedbackStyleLight); + } + return; + } +} + +static void GLHapticsCancelLoopTimer(void) +{ + if (gLoopTimer != nil) + { + [gLoopTimer invalidate]; + gLoopTimer = nil; + } + gLoopPresetId = GLHapticPresetIdNone; +} + +static void GLHapticsLoopTick(NSTimer *timer) +{ + (void)timer; + GLHapticsPlayPresetById(gLoopPresetId); +} + +void _GameLoversHapticsPreset(int presetId) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + GLHapticsPlayPresetById((GLHapticPresetId)presetId); + }); +} + +void _GameLoversHapticsLoopStart(int presetId) +{ + // Loop interval is intentionally short (~120ms) so the device feels continuously vibrating; + // each tick re-fires the system feedback generator which emits a sub-100ms haptic. + dispatch_async(dispatch_get_main_queue(), ^{ + GLHapticsCancelLoopTimer(); + gLoopPresetId = (GLHapticPresetId)presetId; + GLHapticsPlayPresetById(gLoopPresetId); + gLoopTimer = [NSTimer scheduledTimerWithTimeInterval:0.12 + repeats:YES + block:^(NSTimer *t) { GLHapticsLoopTick(t); }]; + }); +} + +void _GameLoversHapticsCustom(float intensity, float durationMs) +{ + // UIKit feedback generators don't expose intensity directly. We approximate by mapping the + // [0,1] intensity to one of three impact styles, fire it, and (if duration > ~150ms) loop a + // similar pattern until the C# auto-stop coroutine fires _GameLoversHapticsStop(). + dispatch_async(dispatch_get_main_queue(), ^{ + UIImpactFeedbackStyle style; + if (intensity < 0.34f) + { + style = UIImpactFeedbackStyleLight; + } + else if (intensity < 0.67f) + { + style = UIImpactFeedbackStyleMedium; + } + else + { + style = UIImpactFeedbackStyleHeavy; + } + GLHapticsPlayImpact(style); + + // Custom haptics with finite duration > ~150ms loop the chosen style on a timer until Stop. + if (durationMs > 150.0f) + { + GLHapticsCancelLoopTimer(); + // Re-use the loop timer infrastructure: pretend it's the matching impact preset. + switch (style) + { + case UIImpactFeedbackStyleLight: gLoopPresetId = GLHapticPresetIdImpactLight; break; + case UIImpactFeedbackStyleMedium: gLoopPresetId = GLHapticPresetIdImpactMedium; break; + case UIImpactFeedbackStyleHeavy: gLoopPresetId = GLHapticPresetIdImpactHeavy; break; + default: gLoopPresetId = GLHapticPresetIdImpactMedium; break; + } + gLoopTimer = [NSTimer scheduledTimerWithTimeInterval:0.12 + repeats:YES + block:^(NSTimer *t) { GLHapticsLoopTick(t); }]; + } + }); +} + +void _GameLoversHapticsStop(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + GLHapticsCancelLoopTimer(); + gSelectionGen = nil; + gNotificationGen = nil; + gImpactGen = nil; + }); +} diff --git a/Plugins/iOS/Haptics.m.meta b/Plugins/iOS/Haptics.m.meta new file mode 100644 index 0000000..dda52a5 --- /dev/null +++ b/Plugins/iOS/Haptics.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ebdd587bcd34d45e89dfe5a178b30501 \ No newline at end of file diff --git a/Plugins/iOS/NativeUi.m b/Plugins/iOS/NativeUi.m index 4882bfa..12eb999 100644 --- a/Plugins/iOS/NativeUi.m +++ b/Plugins/iOS/NativeUi.m @@ -1,3 +1,6 @@ +#import +#import + extern UIViewController *UnityGetGLViewController(); NSString *ToNSString(char* string) { @@ -6,35 +9,120 @@ typedef void (*AlertButtonCallback)(const char * str); -void AlertMessage (bool isSheet, char* title, char* message, char* buttonsText[], int buttonsStyle[], int buttonsLength, AlertButtonCallback buttonCallback) +void AlertMessage (bool isSheet, char* title, char* message, char* buttonsText[], int buttonsStyle[], int buttonsLength, AlertButtonCallback buttonCallback) { UIAlertControllerStyle style = isSheet ? UIAlertControllerStyleActionSheet : UIAlertControllerStyleAlert; UIAlertController *alert = [UIAlertController alertControllerWithTitle:ToNSString(title) message:ToNSString(message) preferredStyle:style]; - for (int i = 0; i < buttonsLength; i++) + for (int i = 0; i < buttonsLength; i++) { NSString *buttonText = ToNSString(buttonsText[i]); int index = i; - UIAlertAction * button = [UIAlertAction actionWithTitle:buttonText style:(UIAlertActionStyle)buttonsStyle[i] handler:^(UIAlertAction * action) + UIAlertAction * button = [UIAlertAction actionWithTitle:buttonText style:(UIAlertActionStyle)buttonsStyle[i] handler:^(UIAlertAction * action) { buttonCallback((char*)[buttonText UTF8String]); }]; [alert addAction:button]; } - + dispatch_async(dispatch_get_main_queue(), ^{ [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil]; }); } -void ToastMessage (char* message, BOOL isLongDuration) +void ToastMessage (char* message, BOOL isLongDuration) { float duration = isLongDuration ? 3.5 : 2; UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:ToNSString(message) preferredStyle:UIAlertControllerStyleAlert]; - + [UnityGetGLViewController() presentViewController:alert animated:YES completion:nil]; - + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, duration * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ [alert dismissViewControllerAnimated:YES completion:nil]; }); } + +void _GameLoversRequestReview(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 14.0, *)) + { + UIViewController *vc = UnityGetGLViewController(); + UIWindowScene *scene = (UIWindowScene *)vc.view.window.windowScene; + if (scene != nil) + { + [SKStoreReviewController requestReviewInScene:scene]; + return; + } + } + // iOS 10.3 - 13.x fallback (and the unlikely case where windowScene is nil on iOS 14+). + if ([SKStoreReviewController respondsToSelector:@selector(requestReview)]) + { + [SKStoreReviewController requestReview]; + } + }); +} + +void _GameLoversShare(const char *text, const char *url, const char *imagePath) +{ + NSString *nsText = (text != NULL && *text != 0) ? [NSString stringWithUTF8String:text] : nil; + NSString *nsUrl = (url != NULL && *url != 0) ? [NSString stringWithUTF8String:url] : nil; + NSString *nsImagePath = (imagePath != NULL && *imagePath != 0) ? [NSString stringWithUTF8String:imagePath] : nil; + + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableArray *items = [NSMutableArray array]; + + if (nsText != nil) + { + [items addObject:nsText]; + } + + if (nsUrl != nil) + { + NSURL *parsedUrl = [NSURL URLWithString:nsUrl]; + if (parsedUrl != nil) + { + [items addObject:parsedUrl]; + } + else + { + [items addObject:nsUrl]; + } + } + + if (nsImagePath != nil) + { + UIImage *image = [UIImage imageWithContentsOfFile:nsImagePath]; + if (image != nil) + { + [items addObject:image]; + } + else + { + NSLog(@"[GameLovers.MobileServices] Share: failed to load image at path %@", nsImagePath); + } + } + + if ([items count] == 0) + { + NSLog(@"[GameLovers.MobileServices] Share called with no content; skipping."); + return; + } + + UIActivityViewController *activityVc = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil]; + UIViewController *root = UnityGetGLViewController(); + + // iPad popover anchor: centre of the root view, zero-sized rect (avoids assertion). + if (activityVc.popoverPresentationController != nil) + { + activityVc.popoverPresentationController.sourceView = root.view; + activityVc.popoverPresentationController.sourceRect = CGRectMake(CGRectGetMidX(root.view.bounds), + CGRectGetMidY(root.view.bounds), + 0, + 0); + activityVc.popoverPresentationController.permittedArrowDirections = 0; + } + + [root presentViewController:activityVc animated:YES completion:nil]; + }); +} diff --git a/Plugins/iOS/Permissions.m b/Plugins/iOS/Permissions.m new file mode 100644 index 0000000..b60ebb0 --- /dev/null +++ b/Plugins/iOS/Permissions.m @@ -0,0 +1,267 @@ +#import +#import +#import +#import +#import + +extern void UnitySendMessage(const char *gameObject, const char *method, const char *message); + +// Mirrors GameLovers.MobileServices.Device.AppPermission enum. +typedef NS_ENUM(NSInteger, GLAppPermission) +{ + GLAppPermissionCamera = 0, + GLAppPermissionMicrophone = 1, + GLAppPermissionLocationWhenInUse = 2, + GLAppPermissionLocationAlways = 3, + GLAppPermissionPhotoLibrary = 4, + GLAppPermissionPhotoLibraryAddOnly = 5, + GLAppPermissionNotifications = 6 +}; + +// Mirrors GameLovers.MobileServices.Device.PermissionStatus enum. +typedef NS_ENUM(NSInteger, GLPermissionStatus) +{ + GLPermissionStatusNotDetermined = 0, + GLPermissionStatusDenied = 1, + GLPermissionStatusGranted = 2, + GLPermissionStatusRestricted = 3 +}; + +// Held to keep CLLocationManager alive long enough for the delegate callback. +@interface GLLocationDelegate : NSObject +@property (nonatomic, assign) int requestId; +@property (nonatomic, copy) NSString *callbackGameObject; +@property (nonatomic, copy) NSString *callbackMethod; +@property (nonatomic, strong) CLLocationManager *manager; +@end + +@implementation GLLocationDelegate + +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager +{ + GLPermissionStatus status = GLPermissionStatusNotDetermined; + switch (manager.authorizationStatus) + { + case kCLAuthorizationStatusAuthorizedAlways: + case kCLAuthorizationStatusAuthorizedWhenInUse: + status = GLPermissionStatusGranted; + break; + case kCLAuthorizationStatusDenied: + status = GLPermissionStatusDenied; + break; + case kCLAuthorizationStatusRestricted: + status = GLPermissionStatusRestricted; + break; + case kCLAuthorizationStatusNotDetermined: + default: + return; // Wait for the user to actually decide before responding. + } + + NSString *payload = [NSString stringWithFormat:@"%d:%ld", _requestId, (long)status]; + UnitySendMessage([_callbackGameObject UTF8String], [_callbackMethod UTF8String], [payload UTF8String]); + + _manager.delegate = nil; + _manager = nil; +} + +@end + +static NSMutableArray *gLocationDelegates = nil; + +static GLPermissionStatus MapAVAuthorizationStatus(AVAuthorizationStatus s) +{ + switch (s) + { + case AVAuthorizationStatusAuthorized: return GLPermissionStatusGranted; + case AVAuthorizationStatusDenied: return GLPermissionStatusDenied; + case AVAuthorizationStatusRestricted: return GLPermissionStatusRestricted; + case AVAuthorizationStatusNotDetermined: return GLPermissionStatusNotDetermined; + default: return GLPermissionStatusNotDetermined; + } +} + +static GLPermissionStatus MapPHAuthorizationStatus(PHAuthorizationStatus s) +{ + switch (s) + { + case PHAuthorizationStatusAuthorized: return GLPermissionStatusGranted; + case PHAuthorizationStatusLimited: return GLPermissionStatusGranted; + case PHAuthorizationStatusDenied: return GLPermissionStatusDenied; + case PHAuthorizationStatusRestricted: return GLPermissionStatusRestricted; + case PHAuthorizationStatusNotDetermined: + default: return GLPermissionStatusNotDetermined; + } +} + +static GLPermissionStatus MapUNAuthorizationStatus(UNAuthorizationStatus s) +{ + switch (s) + { + case UNAuthorizationStatusAuthorized: return GLPermissionStatusGranted; + case UNAuthorizationStatusProvisional: return GLPermissionStatusGranted; + case UNAuthorizationStatusDenied: return GLPermissionStatusDenied; + case UNAuthorizationStatusEphemeral: return GLPermissionStatusGranted; + case UNAuthorizationStatusNotDetermined: + default: return GLPermissionStatusNotDetermined; + } +} + +static GLPermissionStatus MapCLAuthorizationStatusValue(CLAuthorizationStatus s) +{ + switch (s) + { + case kCLAuthorizationStatusAuthorizedAlways: + case kCLAuthorizationStatusAuthorizedWhenInUse: + return GLPermissionStatusGranted; + case kCLAuthorizationStatusDenied: + return GLPermissionStatusDenied; + case kCLAuthorizationStatusRestricted: + return GLPermissionStatusRestricted; + case kCLAuthorizationStatusNotDetermined: + default: + return GLPermissionStatusNotDetermined; + } +} + +int _GameLoversPermissionsCheck(int permissionId) +{ + GLAppPermission p = (GLAppPermission)permissionId; + switch (p) + { + case GLAppPermissionCamera: + return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]); + case GLAppPermissionMicrophone: + return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]); + case GLAppPermissionLocationWhenInUse: + case GLAppPermissionLocationAlways: + return MapCLAuthorizationStatusValue([CLLocationManager authorizationStatus]); + case GLAppPermissionPhotoLibrary: + if (@available(iOS 14.0, *)) + { + return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]); + } + return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]); + case GLAppPermissionPhotoLibraryAddOnly: + if (@available(iOS 14.0, *)) + { + return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelAddOnly]); + } + return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]); + case GLAppPermissionNotifications: + { + __block GLPermissionStatus result = GLPermissionStatusNotDetermined; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) { + result = MapUNAuthorizationStatus(settings.authorizationStatus); + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC)); + return result; + } + } + return GLPermissionStatusNotDetermined; +} + +static void GLSendResult(int requestId, const char *callbackGameObject, const char *callbackMethod, GLPermissionStatus status) +{ + NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)status]; + UnitySendMessage(callbackGameObject, callbackMethod, [payload UTF8String]); +} + +void _GameLoversPermissionsRequest(int permissionId, int requestId, const char *callbackGameObject, const char *callbackMethod) +{ + NSString *goName = [NSString stringWithUTF8String:callbackGameObject]; + NSString *methodName = [NSString stringWithUTF8String:callbackMethod]; + GLAppPermission p = (GLAppPermission)permissionId; + + switch (p) + { + case GLAppPermissionCamera: + { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], + granted ? GLPermissionStatusGranted : GLPermissionStatusDenied); + }]; + return; + } + case GLAppPermissionMicrophone: + { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], + granted ? GLPermissionStatusGranted : GLPermissionStatusDenied); + }]; + return; + } + case GLAppPermissionLocationWhenInUse: + case GLAppPermissionLocationAlways: + { + if (gLocationDelegates == nil) + { + gLocationDelegates = [NSMutableArray array]; + } + + GLLocationDelegate *delegate = [[GLLocationDelegate alloc] init]; + delegate.requestId = requestId; + delegate.callbackGameObject = goName; + delegate.callbackMethod = methodName; + delegate.manager = [[CLLocationManager alloc] init]; + delegate.manager.delegate = delegate; + [gLocationDelegates addObject:delegate]; + + if (p == GLAppPermissionLocationAlways) + { + [delegate.manager requestAlwaysAuthorization]; + } + else + { + [delegate.manager requestWhenInUseAuthorization]; + } + return; + } + case GLAppPermissionPhotoLibrary: + { + if (@available(iOS 14.0, *)) + { + [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite handler:^(PHAuthorizationStatus status) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status)); + }]; + } + else + { + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status)); + }]; + } + return; + } + case GLAppPermissionPhotoLibraryAddOnly: + { + if (@available(iOS 14.0, *)) + { + [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly handler:^(PHAuthorizationStatus status) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status)); + }]; + } + else + { + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], MapPHAuthorizationStatus(status)); + }]; + } + return; + } + case GLAppPermissionNotifications: + { + UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound; + [[UNUserNotificationCenter currentNotificationCenter] + requestAuthorizationWithOptions:options + completionHandler:^(BOOL granted, NSError * _Nullable error) { + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], + granted ? GLPermissionStatusGranted : GLPermissionStatusDenied); + }]; + return; + } + } + + GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], GLPermissionStatusNotDetermined); +} diff --git a/Plugins/iOS/Permissions.m.meta b/Plugins/iOS/Permissions.m.meta new file mode 100644 index 0000000..959f087 --- /dev/null +++ b/Plugins/iOS/Permissions.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13fd05a91c602491a95d0e2c2a105ffe \ No newline at end of file diff --git a/Plugins/iOS/iOSAudioSession.m b/Plugins/iOS/iOSAudioSession.m new file mode 100644 index 0000000..6773058 --- /dev/null +++ b/Plugins/iOS/iOSAudioSession.m @@ -0,0 +1,21 @@ +#import + +void _SetAudioSessionPlayback(void) +{ + NSError *err = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + + BOOL ok = [session setCategory:AVAudioSessionCategoryPlayback error:&err]; + if (!ok || err != nil) + { + NSLog(@"[GameLovers.MobileServices] setCategory:AVAudioSessionCategoryPlayback failed: %@", err); + return; + } + + err = nil; + ok = [session setActive:YES error:&err]; + if (!ok || err != nil) + { + NSLog(@"[GameLovers.MobileServices] AVAudioSession setActive:YES failed: %@", err); + } +} diff --git a/Plugins/iOS/iOSAudioSession.m.meta b/Plugins/iOS/iOSAudioSession.m.meta new file mode 100644 index 0000000..23d2b17 --- /dev/null +++ b/Plugins/iOS/iOSAudioSession.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65784c3c2c0934a54a68f288799021d0 \ No newline at end of file diff --git a/README.md b/README.md index 25ce453..ceaf2c3 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,306 @@ -# GameLovers Mobile Services - -[![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/github/v/tag/CoderGamester/com.gamelovers.mobileservices?label=version)](CHANGELOG.md) - -> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Contributing](#contributing) - -## Why Use This Package? - -Building mobile-specific features in Unity often requires dealing with platform-specific code, native bridges, and fragmented APIs. This **Mobile Services** package consolidates essential mobile functionality into a unified, easy-to-use API: - -| Problem | Solution | -|---------|----------| -| **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, and review prompts with one API | -| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management | -| **Custom gesture detection** | Gesture controller provides swipe and tap detection via Unity's EnhancedTouch | -| **Editor testing challenges** | Editor fallbacks for all features enable testing without device builds | - -**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Tested in real mobile games. - ---- - -## System Requirements - -- **[Unity](https://unity.com/download)** 6000.0+ (Unity 6) -- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) — automatically resolved -- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) — automatically resolved - -| Platform | Status | -|---|---| -| iOS | ✅ Supported | -| Android | ✅ Supported | -| Editor | ✅ Supported (fallbacks) | -| Standalone | ⚠️ Gestures only; no native UI/notifications | -| WebGL | ❌ Not Supported | - -## Installation - -### Via Unity Package Manager (Recommended) - -1. Open Unity Package Manager (`Window` → `Package Manager`) -2. Click `+` → `Add package from git URL` -3. Enter: `https://github.com/CoderGamester/com.gamelovers.mobileservices.git` - -### Via manifest.json - -```json -{ - "dependencies": { - "com.gamelovers.mobileservices": "https://github.com/CoderGamester/com.gamelovers.mobileservices.git" - } -} -``` - ---- - -## Key Components - -| Component | Responsibility | -|-----------|----------------| -| **NativeUiService** | Static class bridging native iOS/Android UI (alerts, action sheets, toasts) | -| **MobileNotificationService** | Notification scheduling, cancellation, and channel management | -| **IGameNotification** | Platform-agnostic notification interface | -| **GestureController** | MonoBehaviour detecting swipe and tap gestures via EnhancedTouch | -| **SwipeInput** | Data structure with swipe direction, velocity, and consistency metrics | -| **TapInput** | Data structure for tap position and finger data | - ---- - -## Quick Start - -### Native UI - -```csharp -using GameLovers.MobileServices.NativeUi; - -NativeUiService.ShowAlertPopUp( - darkMode: false, - title: "Delete Save?", - message: "This action cannot be undone.", - new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, - new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, OnClick = OnDeleteConfirmed } -); - -NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); // Android only -NativeUiService.RequestReview(); -``` - -### Notifications - -```csharp -using GameLovers.MobileServices.Notifications; - -var service = new MobileNotificationService( - new GameNotificationChannel("default", "Default", "Default notifications"), - new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") -); - -var notification = service.CreateNotification(); -notification.Title = "Daily Reward Ready!"; -notification.Body = "Your daily reward is waiting for you!"; -notification.DeliveryTime = DateTime.Now.AddHours(24); -notification.Channel = "rewards"; -service.ScheduleNotification(notification); -``` - -### Gesture Detection - -```csharp -using GameLovers.MobileServices.Gestures; - -// Attach GestureController MonoBehaviour to a scene GameObject -// Note: uses Unity's EnhancedTouch API; in Editor add a TouchSimulation component for mouse input - -_gestureController.Swiped += swipe => -{ - // swipe.SwipeDirection — Up / Down / Left / Right - // swipe.SwipeVelocity — speed of the swipe - // swipe.SwipeSameness — direction consistency 0–1 (higher = cleaner) - if (swipe.SwipeSameness > 0.8f) - ProcessSwipe(swipe.SwipeDirection); -}; - -_gestureController.Tapped += tap => -{ - // tap.Position — screen position of the tap - Debug.Log($"Tapped at {tap.Position}"); -}; -``` - ---- - -## Services at a Glance - -### Native UI - -All methods are **static** — no initialization needed. The service is platform-gated: no-op in the Editor (logs only), throws on unsupported platforms. - -| Method | Platform | -|--------|----------| -| `ShowAlertPopUp(darkMode, title, message, buttons…)` | iOS + Android | -| `ShowToastMessage(message, isLongDuration)` | Android only | -| `RequestReview()` | iOS (`SKStoreReviewController`) + Android (Play In-App Review) | - -**Alert Button Styles:** `Default`, `Cancel`, `Destructive` - -### Notification Service - -```csharp -service.CancelNotification(pending.Id); -service.CancelAllNotifications(); -var scheduled = service.GetPendingNotifications(); -``` - -Key points: -- Android requires at least one channel; the first passed becomes the default. -- Creates a `DontDestroyOnLoad` host GameObject — teardown explicitly in tests or game reset flows. -- `OperatingMode.Queue*` defers scheduling to the OS until the app backgrounds. - -### Gesture Controller - -Key points: -- Powered by Unity's `EnhancedTouch` API — `EnhancedTouchSupport` is enabled/disabled automatically in `OnEnable`/`OnDisable`. -- For mouse input in Editor: add a `TouchSimulation` component. -- If `minSwipeDistance <= maxTapDrift`, an interaction may qualify as both tap and swipe — tune thresholds carefully. - -**SwipeInput fields:** - -| Field | Type | Description | -|---|---|---| -| `SwipeDirection` | `SwipeDirection` | Up / Down / Left / Right | -| `SwipeVelocity` | `float` | Speed of the gesture | -| `SwipeSameness` | `float` | Direction consistency 0–1 | -| `StartPosition` | `Vector2` | Screen start position | -| `EndPosition` | `Vector2` | Screen end position | - ---- - -## Platform-Specific Notes - -**iOS:** Native UI via Objective-C bridge (`Plugins/iOS/NativeUi.m`). Alert callbacks matched by button text — keep button texts unique per alert. - -**Android:** Native UI via `AndroidJavaClass` reflection. Notifications require channels (Android 8.0+). - -**Editor:** Alerts and toasts log to console. Notifications are logged but not scheduled. Gestures work via `TouchSimulation`. - ---- - -## Contributing - -Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). - ---- - -## Related docs - -| Document | Purpose | -|---|---| -| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | -| [CHANGELOG.md](CHANGELOG.md) | Version history | - -## Support - -- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) -- **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/com.gamelovers.mobileservices/discussions) - -## License - -MIT — see [LICENSE.md](LICENSE.md). +# GameLovers Mobile Services + +[![Unity Version](https://img.shields.io/badge/Unity-6000.0%2B-blue.svg)](https://unity3d.com/get-unity/download) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Version](https://img.shields.io/github/v/tag/CoderGamester/com.gamelovers.mobileservices?label=version)](CHANGELOG.md) + +> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Contributing](#contributing) + +## Why Use This Package? + +Building mobile-specific features in Unity often requires dealing with platform-specific code, native bridges, and fragmented APIs. This **Mobile Services** package consolidates essential mobile functionality into a unified, easy-to-use API: + +| Problem | Solution | +|---------|----------| +| **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, review prompts, and share sheets with one API | +| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management | +| **Custom gesture detection** | Gesture controller provides swipe and tap detection via Unity's EnhancedTouch | +| **Haptic plugin sprawl** | Zero-dependency `IHapticsService` with 9 presets, custom intensity, and time-bounded looping — built directly on iOS/Android primitives | +| **Scattered device APIs** | One `IDeviceService` umbrella over `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, `DeepLink` — each child also independently mockable | +| **iOS silent switch muting audio** | `device.AudioSession.ConfigureForPlayback()` overrides `AVAudioSession` category in one line | +| **iOS App Tracking Transparency** | `device.Att.RequestAuthorizationAsync()` — direct `ATTrackingManager` bridge, no `com.unity.ads.ios-support` dependency | +| **Cold-start deep link loss** | `device.DeepLink` queues the launch link for the first subscriber so you never miss it | +| **Editor testing challenges** | Editor fallbacks for all features enable testing without device builds | + +**Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Tested in real mobile games. + +--- + +## System Requirements + +- **[Unity](https://unity.com/download)** 6000.0+ (Unity 6) +- **[Unity Mobile Notifications](https://docs.unity3d.com/Packages/com.unity.mobile.notifications@latest)** (2.3.0) — automatically resolved +- **[Unity Input System](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest)** (1.11.0) — automatically resolved + +| Platform | Status | +|---|---| +| iOS | ✅ Fully supported | +| Android | ✅ Fully supported | +| Editor | ✅ Supported (no-op fallbacks for all native services) | +| Standalone | ⚠️ Gestures + Connectivity + SafeArea + Battery (level/status); Haptics returns `IsSupported = false`; iOS audio session / ATT are no-ops | +| WebGL | ❌ Not supported | + +## Installation + +### Via Unity Package Manager (Recommended) + +1. Open Unity Package Manager (`Window` → `Package Manager`) +2. Click `+` → `Add package from git URL` +3. Enter: `https://github.com/CoderGamester/com.gamelovers.mobileservices.git` + +### Via manifest.json + +```json +{ + "dependencies": { + "com.gamelovers.mobileservices": "https://github.com/CoderGamester/com.gamelovers.mobileservices.git" + } +} +``` + +--- + +## Key Components + +| Component | Responsibility | +|-----------|----------------| +| **NativeUiService** | Static class bridging native iOS/Android UI (alerts, action sheets, toasts) | +| **MobileNotificationService** | Notification scheduling, cancellation, and channel management | +| **IGameNotification** | Platform-agnostic notification interface | +| **GestureController** | MonoBehaviour detecting swipe and tap gestures via EnhancedTouch | +| **SwipeInput** | Data structure with swipe direction, velocity, and consistency metrics | +| **TapInput** | Data structure for tap position and finger data | +| **IIosAudioSessionService** | Overrides the iOS silent switch so audio keeps playing (no-op elsewhere) | +| **IHapticsService** | Cross-platform haptic feedback with 9 presets, custom intensity, time-bounded looping. Zero third-party deps. | +| **IDeviceService** | Umbrella facade exposing `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, `DeepLink` | +| **IPermissionsService** | Unified iOS+Android runtime permissions (Camera, Mic, Location, Photos, Notifications) — Task-based async | +| **IAttService** | iOS App Tracking Transparency. Built directly on `ATTrackingManager` — no `com.unity.ads.ios-support` dep | +| **IDeepLinkService** | `Application.deepLinkActivated` wrapper with cold-start link queueing | +| **ISafeAreaService** | `Screen.safeArea` with change events; pairs with `SafeAreaContainer` UI Toolkit element | +| **IBatteryService** | Battery level/status + iOS/Android low-power-mode awareness with events | +| **IConnectivityService** | `Application.internetReachability` with change events | +| **IScreenWakeService** | `KeepAwake` toggle over `Screen.sleepTimeout` | + +--- + +## Quick Start + +### Native UI + +```csharp +using GameLovers.MobileServices.NativeUi; + +NativeUiService.ShowAlertPopUp( + isAlertSheet: false, + title: "Delete Save?", + message: "This action cannot be undone.", + new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, + new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, Callback = OnDeleteConfirmed } +); + +NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); + +// OS-mediated rating prompt (no-op in Editor; iOS SKStoreReviewController + Android Play In-App Review). +NativeUiService.RequestReview(); + +// OS share sheet. Pass any combination of text/url/imagePath; nulls are skipped. +NativeUiService.Share(text: "Check out my high score!", url: "https://example.com/game"); +``` + +### Notifications + +```csharp +using GameLovers.MobileServices.Notifications; + +var service = new MobileNotificationService( + new GameNotificationChannel("default", "Default", "Default notifications"), + new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") +); + +var notification = service.CreateNotification(); +notification.Title = "Daily Reward Ready!"; +notification.Body = "Your daily reward is waiting for you!"; +notification.DeliveryTime = DateTime.Now.AddHours(24); +notification.Channel = "rewards"; +service.ScheduleNotification(notification); +``` + +### iOS Audio Session + +```csharp +using GameLovers.MobileServices.Device; + +var audio = new IosAudioSessionService(); +audio.ConfigureForPlayback(); // Call once at startup. No-op on Android / Editor. +``` + +### Device Services (umbrella) + +```csharp +using GameLovers.MobileServices.Device; + +IDeviceService device = new DeviceService(); + +// Battery + low-power mode. +device.Battery.OnLowPowerModeChanged += () => + Debug.Log($"LPM changed -> {device.Battery.IsLowPowerMode}"); + +// Connectivity events. +device.Connectivity.OnStatusChanged += status => + Debug.Log($"Reachability changed -> {status}"); + +// Safe area for UI Toolkit. +var safeAreaContainer = new SafeAreaContainer(device.SafeArea); +rootVisualElement.Add(safeAreaContainer); + +// Keep the screen awake during gameplay. +device.ScreenWake.KeepAwake = true; + +// Override iOS silent switch. +device.AudioSession.ConfigureForPlayback(); + +// Runtime permissions (Task-based; no UniTask dependency). +var camera = await device.Permissions.RequestAsync(AppPermission.Camera); +if (camera == PermissionStatus.Granted) { /* … */ } + +// App Tracking Transparency (iOS 14.5+; returns Authorized on Android/Editor). +var att = await device.Att.RequestAuthorizationAsync(); + +// Deep links — cold-start safe; subscribe whenever, never miss a launch link. +device.DeepLink.OnLinkActivated += uri => Debug.Log($"Deep link: {uri}"); +``` + +Each child interface is also independently registerable for tests, so you can mock `IBatteryService` directly without going through the facade. + +### Haptics + +```csharp +using GameLovers.MobileServices.Haptics; + +IHapticsService haptics = new HapticsService(); + +// Natural one-shot for the preset's built-in duration. +haptics.PlayPreset(HapticPreset.Success); + +// Loop indefinitely until you call StopCurrentHaptic(). +haptics.PlayPresetDuration(HapticPreset.ImpactMedium, duration: -1f); +// ... later ... +haptics.StopCurrentHaptic(); + +// Loop and auto-stop after 0.5 seconds. +haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, duration: 0.5f); + +// Custom intensity (0..1) with explicit duration in milliseconds. +haptics.PlayCustom(intensity01: 0.7f, durationMs: 250f); + +// Master toggle. Setting Enabled=false also stops any active haptic. +haptics.Enabled = false; +``` + +### Gesture Detection + +```csharp +using GameLovers.MobileServices.Gestures; + +// Attach GestureController MonoBehaviour to a scene GameObject +// Note: uses Unity's EnhancedTouch API; in Editor add a TouchSimulation component for mouse input + +_gestureController.Swiped += swipe => +{ + // swipe.SwipeDirection — Up / Down / Left / Right + // swipe.SwipeVelocity — speed of the swipe + // swipe.SwipeSameness — direction consistency 0–1 (higher = cleaner) + if (swipe.SwipeSameness > 0.8f) + ProcessSwipe(swipe.SwipeDirection); +}; + +_gestureController.Tapped += tap => +{ + // tap.Position — screen position of the tap + Debug.Log($"Tapped at {tap.Position}"); +}; +``` + +--- + +## Services at a Glance + +### Native UI + +All methods are **static** — no initialization needed. The service is platform-gated: no-op in the Editor (logs only), throws on unsupported platforms. + +| Method | Platform | +|--------|----------| +| `ShowAlertPopUp(isAlertSheet, title, message, buttons…)` | iOS + Android | +| `ShowToastMessage(message, isLongDuration)` | iOS + Android | +| `RequestReview()` | iOS (`SKStoreReviewController`) + Android (Play In-App Review) | +| `Share(text, url, imagePath, title)` | iOS (`UIActivityViewController`) + Android (`Intent.ACTION_SEND`) | + +**Alert Button Styles:** `Default`, `Cancel`, `Destructive` + +> **Android `RequestReview()`** requires the Play Core Review library. Add to `mainTemplate.gradle`: +> `implementation 'com.google.android.play:review:2.0.1'` + +### Notification Service + +```csharp +service.CancelNotification(pending.Id); +service.CancelAllScheduledNotifications(); +var scheduled = service.PendingNotifications; +``` + +Key points: +- Android requires at least one channel; the first passed becomes the default. +- Creates a `DontDestroyOnLoad` host GameObject — teardown explicitly in tests or game reset flows. +- `OperatingMode.Queue*` defers scheduling to the OS until the app backgrounds. + +### Gesture Controller + +Key points: +- Powered by Unity's `EnhancedTouch` API — `EnhancedTouchSupport` is enabled/disabled automatically in `OnEnable`/`OnDisable`. +- For mouse input in Editor: add a `TouchSimulation` component. +- If `minSwipeDistance <= maxTapDrift`, an interaction may qualify as both tap and swipe — tune thresholds carefully. + +**SwipeInput fields:** + +| Field | Type | Description | +|---|---|---| +| `SwipeDirection` | `SwipeDirection` | Up / Down / Left / Right | +| `SwipeVelocity` | `float` | Speed of the gesture | +| `SwipeSameness` | `float` | Direction consistency 0–1 | +| `StartPosition` | `Vector2` | Screen start position | +| `EndPosition` | `Vector2` | Screen end position | + +--- + +## Platform-Specific Notes + +**iOS:** Native UI via Objective-C bridge (`Plugins/iOS/NativeUi.m`). Alert callbacks matched by button text — keep button texts unique per alert. + +**Android:** Native UI via `AndroidJavaClass` reflection. Notifications require channels (Android 8.0+). + +**Editor:** Alerts and toasts log to console. Notifications are logged but not scheduled. Gestures work via `TouchSimulation`. + +--- + +## Contributing + +Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). + +--- + +## Related docs + +| Document | Purpose | +|---|---| +| [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | +| [CHANGELOG.md](CHANGELOG.md) | Version history | + +## Support + +- **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) +- **Discussions**: [Ask questions and share ideas](https://github.com/CoderGamester/com.gamelovers.mobileservices/discussions) + +## License + +MIT — see [LICENSE.md](LICENSE.md). diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..f4901cf --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GameLovers.MobileServices.EditMode.Tests")] +[assembly: InternalsVisibleTo("GameLovers.MobileServices.PlayMode.Tests")] diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..cebd878 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2ce3d01102834563a429f601de5100f \ No newline at end of file diff --git a/Runtime/Device.meta b/Runtime/Device.meta new file mode 100644 index 0000000..ef922e9 --- /dev/null +++ b/Runtime/Device.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fb52d3931143c4233bb33bf5c22b89b2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Audio.meta b/Runtime/Device/Audio.meta new file mode 100644 index 0000000..e1eb13b --- /dev/null +++ b/Runtime/Device/Audio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ae95abd8acc764526a9b8235e8ade907 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Audio/IIosAudioSessionService.cs b/Runtime/Device/Audio/IIosAudioSessionService.cs new file mode 100644 index 0000000..b65f99c --- /dev/null +++ b/Runtime/Device/Audio/IIosAudioSessionService.cs @@ -0,0 +1,21 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Configures the iOS audio session so app audio is not muted by the device's silent (mute) switch. + /// On Android / Editor / unsupported platforms every method is a safe no-op. + /// + /// + /// Also exposed via for one-stop discovery. + /// + public interface IIosAudioSessionService + { + /// + /// Sets the iOS AVAudioSession category to AVAudioSessionCategoryPlayback so audio + /// keeps playing even when the device's ringer/silent switch is on. + /// + /// Idempotent and safe to call from any state. Call once at app startup, before any audio plays. + /// + void ConfigureForPlayback(); + } +} diff --git a/Runtime/Device/Audio/IIosAudioSessionService.cs.meta b/Runtime/Device/Audio/IIosAudioSessionService.cs.meta new file mode 100644 index 0000000..e134f04 --- /dev/null +++ b/Runtime/Device/Audio/IIosAudioSessionService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af631fa8889354d7fbd90c40aaf926d5 \ No newline at end of file diff --git a/Runtime/Device/Audio/IosAudioSessionService.cs b/Runtime/Device/Audio/IosAudioSessionService.cs new file mode 100644 index 0000000..0b7b9cd --- /dev/null +++ b/Runtime/Device/Audio/IosAudioSessionService.cs @@ -0,0 +1,34 @@ +using System; +using System.Runtime.InteropServices; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public class IosAudioSessionService : IIosAudioSessionService + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] + private static extern void _SetAudioSessionPlayback(); +#endif + + /// + public void ConfigureForPlayback() + { +#if UNITY_IOS && !UNITY_EDITOR + try + { + _SetAudioSessionPlayback(); + Debug.Log("[GameLovers.MobileServices] iOS audio session configured for playback"); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Failed to configure iOS audio session: {e.Message}"); + } +#else + Debug.Log("[GameLovers.MobileServices] IosAudioSessionService.ConfigureForPlayback skipped (not running on iOS device)"); +#endif + } + } +} diff --git a/Runtime/Device/Audio/IosAudioSessionService.cs.meta b/Runtime/Device/Audio/IosAudioSessionService.cs.meta new file mode 100644 index 0000000..d875f28 --- /dev/null +++ b/Runtime/Device/Audio/IosAudioSessionService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a292faee16b24168ad9be1004ee2d4a \ No newline at end of file diff --git a/Runtime/Device/DeepLinks.meta b/Runtime/Device/DeepLinks.meta new file mode 100644 index 0000000..1e183f5 --- /dev/null +++ b/Runtime/Device/DeepLinks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ecb503617a8504ed2a2690f30de29602 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs b/Runtime/Device/DeepLinks/DeepLinkService.cs new file mode 100644 index 0000000..384479d --- /dev/null +++ b/Runtime/Device/DeepLinks/DeepLinkService.cs @@ -0,0 +1,74 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class DeepLinkService : IDeepLinkService, IDisposable + { + private Action _onLinkActivated; + private Uri _pendingColdStartLink; + + /// + public Uri PendingColdStartLink => _pendingColdStartLink; + + /// + public event Action OnLinkActivated + { + add + { + _onLinkActivated += value; + if (_pendingColdStartLink == null) + { + return; + } + var pending = _pendingColdStartLink; + _pendingColdStartLink = null; + value(pending); + } + remove => _onLinkActivated -= value; + } + + public DeepLinkService() + { + Application.deepLinkActivated += OnDeepLinkActivated; + + // If the app was cold-launched with a deep link, Application.absoluteURL is non-empty + // before any subscriber attaches. Capture it here and replay on the first subscription. + if (!string.IsNullOrEmpty(Application.absoluteURL)) + { + _pendingColdStartLink = TryParse(Application.absoluteURL); + } + } + + public void Dispose() + { + Application.deepLinkActivated -= OnDeepLinkActivated; + _onLinkActivated = null; + _pendingColdStartLink = null; + } + + private void OnDeepLinkActivated(string url) + { + var parsed = TryParse(url); + if (parsed == null) + { + return; + } + + // Runtime delivery supersedes / consumes the pending cold-start link. + _pendingColdStartLink = null; + _onLinkActivated?.Invoke(parsed); + } + + private static Uri TryParse(string url) + { + if (string.IsNullOrEmpty(url)) + { + return null; + } + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) ? parsed : null; + } + } +} diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs.meta b/Runtime/Device/DeepLinks/DeepLinkService.cs.meta new file mode 100644 index 0000000..e2f3f31 --- /dev/null +++ b/Runtime/Device/DeepLinks/DeepLinkService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3541bcc9b6e0649409dbe83108a26535 \ No newline at end of file diff --git a/Runtime/Device/DeepLinks/IDeepLinkService.cs b/Runtime/Device/DeepLinks/IDeepLinkService.cs new file mode 100644 index 0000000..b280688 --- /dev/null +++ b/Runtime/Device/DeepLinks/IDeepLinkService.cs @@ -0,0 +1,25 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Wraps Application.deepLinkActivated with cold-start link queueing — links the OS handed + /// the app at launch are not lost if the first subscriber attaches + /// after the event has already fired. + /// + public interface IDeepLinkService + { + /// + /// Fires whenever the OS delivers a deep link. If the app was cold-launched with a link and + /// no subscriber was attached yet, the link is replayed to the first subscriber. + /// + event Action OnLinkActivated; + + /// + /// The cold-start link, if any. Reads as null after the first + /// subscriber consumes it (or after the first delivered runtime link, whichever comes first). + /// + Uri PendingColdStartLink { get; } + } +} diff --git a/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta b/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta new file mode 100644 index 0000000..f3fff49 --- /dev/null +++ b/Runtime/Device/DeepLinks/IDeepLinkService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9307ee40aeeb1433a83af064d917b910 \ No newline at end of file diff --git a/Runtime/Device/DeviceService.cs b/Runtime/Device/DeviceService.cs new file mode 100644 index 0000000..4432c74 --- /dev/null +++ b/Runtime/Device/DeviceService.cs @@ -0,0 +1,91 @@ +using System; +using GameLovers.MobileServices.Device.Internal; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class DeviceService : IDeviceService, IDisposable + { + /// + public ISafeAreaService SafeArea { get; } + /// + public IScreenWakeService ScreenWake { get; } + /// + public IBatteryService Battery { get; } + /// + public IConnectivityService Connectivity { get; } + /// + public IIosAudioSessionService AudioSession { get; } + /// + public IPermissionsService Permissions { get; } + /// + public IAttService Att { get; } + /// + public IDeepLinkService DeepLink { get; } + + /// + /// Constructs the umbrella with the default child implementations for the current platform. + /// All host-dependent children (SafeArea, Battery, Connectivity) share a single + /// spawned by this constructor — no extra GameObjects. + /// + public DeviceService() : this(BuildDefaults()) { } + + /// + /// Constructs the umbrella with injected children. Used by tests to supply mocks. + /// Children that implement will be disposed by . + /// + public DeviceService( + ISafeAreaService safeArea, + IScreenWakeService screenWake, + IBatteryService battery, + IConnectivityService connectivity, + IIosAudioSessionService audioSession, + IPermissionsService permissions, + IAttService att, + IDeepLinkService deepLink) + { + SafeArea = safeArea; + ScreenWake = screenWake; + Battery = battery; + Connectivity = connectivity; + AudioSession = audioSession; + Permissions = permissions; + Att = att; + DeepLink = deepLink; + } + + // Tuple-routed delegating ctor so the 3 host-dependent children share one explicit host + // instance constructed up-front, not 3 separate accesses to the singleton during a + // constructor chain (cleaner ownership signal in the umbrella's call stack). + private DeviceService((ISafeAreaService, IScreenWakeService, IBatteryService, IConnectivityService, + IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) defaults) + : this(defaults.Item1, defaults.Item2, defaults.Item3, defaults.Item4, + defaults.Item5, defaults.Item6, defaults.Item7, defaults.Item8) + { + } + + private static (ISafeAreaService, IScreenWakeService, IBatteryService, IConnectivityService, + IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) BuildDefaults() + { + var host = DeviceServicesHost.Instance; + return ( + new SafeAreaService(host), + new ScreenWakeService(), + new BatteryService(host), + new ConnectivityService(host), + new IosAudioSessionService(), + new PermissionsService(), + new AttService(), + new DeepLinkService()); + } + + public void Dispose() + { + (SafeArea as IDisposable)?.Dispose(); + (Battery as IDisposable)?.Dispose(); + (Connectivity as IDisposable)?.Dispose(); + (DeepLink as IDisposable)?.Dispose(); + } + } +} diff --git a/Runtime/Device/DeviceService.cs.meta b/Runtime/Device/DeviceService.cs.meta new file mode 100644 index 0000000..a715a06 --- /dev/null +++ b/Runtime/Device/DeviceService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5449557811d2b4b0a8e953804773afff \ No newline at end of file diff --git a/Runtime/Device/IDeviceService.cs b/Runtime/Device/IDeviceService.cs new file mode 100644 index 0000000..8c35fb4 --- /dev/null +++ b/Runtime/Device/IDeviceService.cs @@ -0,0 +1,35 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Umbrella facade aggregating every device-touching service in the package. Use as a single + /// DI registration to expose the full Device subsystem; each child interface is also + /// independently registerable for testing/mocking. + /// + public interface IDeviceService + { + /// Display safe-area events (notch, dynamic island, orientation). + ISafeAreaService SafeArea { get; } + + /// Toggle Screen.sleepTimeout (keep the screen awake). + IScreenWakeService ScreenWake { get; } + + /// Battery level / status / low-power-mode awareness. + IBatteryService Battery { get; } + + /// Network reachability with change events. + IConnectivityService Connectivity { get; } + + /// iOS audio session category override (silent-switch). No-op elsewhere. + IIosAudioSessionService AudioSession { get; } + + /// Unified iOS+Android runtime permissions (Camera, Mic, Location, Photos, Notifications). + IPermissionsService Permissions { get; } + + /// iOS App Tracking Transparency (no-op on Android / Editor / unsupported). + IAttService Att { get; } + + /// OS deep link delivery with cold-start link queueing. + IDeepLinkService DeepLink { get; } + } +} diff --git a/Runtime/Device/IDeviceService.cs.meta b/Runtime/Device/IDeviceService.cs.meta new file mode 100644 index 0000000..d5530f1 --- /dev/null +++ b/Runtime/Device/IDeviceService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88f06b1d4f8da428884c7beab0afeacf \ No newline at end of file diff --git a/Runtime/Device/Internal.meta b/Runtime/Device/Internal.meta new file mode 100644 index 0000000..ea770e1 --- /dev/null +++ b/Runtime/Device/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39dbc9b9b6b814061b06d63d0319e60c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Internal/DeviceServicesHost.cs b/Runtime/Device/Internal/DeviceServicesHost.cs new file mode 100644 index 0000000..508d967 --- /dev/null +++ b/Runtime/Device/Internal/DeviceServicesHost.cs @@ -0,0 +1,152 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device.Internal +{ + /// + /// Internal MonoBehaviour shared by all event-driven Device services (SafeArea, Battery, Connectivity). + /// Spawned lazily on first use, marked DontDestroyOnLoad. Exists so the runtime cost of the + /// Device subsystem is one auto-spawned GameObject instead of one per service. + /// + /// + /// Although every member of this class is logically internal, the + /// entry point is kept public because it is reached + /// by Unity's UnitySendMessage dispatcher from the iOS native bridge + /// (Plugins/iOS/Battery.m). Keeping it explicitly public documents that contract + /// and avoids any future change to Unity's reflection visibility defaults breaking the bridge. + /// + internal sealed class DeviceServicesHost : MonoBehaviour + { + // Per-service callback registration. Each callback is invoked once per Update at most; + // each service is responsible for diffing and firing its own events from the callback. + private event Action _onLateUpdate; + private event Action _onSecondTick; + private event Action _onApplicationFocusChanged; + private event Action _onIosLowPowerModeChanged; + + private float _secondAccumulator; + + private static DeviceServicesHost _instance; + + /// Returns the shared instance, spawning the host GameObject on first call. + internal static DeviceServicesHost Instance + { + get + { + if (_instance != null) + { + return _instance; + } + + var go = new GameObject("DeviceServicesHost"); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + return _instance; + } + } + + /// + /// Tears down the singleton: destroys the GameObject (if any) and clears the static reference. + /// Intended for EditMode tests that need a clean state between runs. + /// + internal static void ResetForTests() + { + if (_instance == null) + { + return; + } + + var go = _instance.gameObject; + _instance = null; + + if (Application.isPlaying) + { + Destroy(go); + } + else + { + DestroyImmediate(go); + } + } + + /// Subscribes to a per-LateUpdate poll tick. + internal void RegisterLateUpdate(Action callback) + { + _onLateUpdate += callback; + } + + internal void UnregisterLateUpdate(Action callback) + { + _onLateUpdate -= callback; + } + + /// Subscribes to a roughly-once-per-second tick (cheaper than LateUpdate; useful for connectivity polling). + internal void RegisterSecondTick(Action callback) + { + _onSecondTick += callback; + } + + internal void UnregisterSecondTick(Action callback) + { + _onSecondTick -= callback; + } + + /// Subscribes to OnApplicationFocus(bool focused). + internal void RegisterFocusChanged(Action callback) + { + _onApplicationFocusChanged += callback; + } + + internal void UnregisterFocusChanged(Action callback) + { + _onApplicationFocusChanged -= callback; + } + + /// Subscribes to the iOS low-power-mode change signal sourced from the native bridge. + internal void RegisterIosLowPowerModeChanged(Action callback) + { + _onIosLowPowerModeChanged += callback; + } + + internal void UnregisterIosLowPowerModeChanged(Action callback) + { + _onIosLowPowerModeChanged -= callback; + } + + // MUST stay public: invoked by Unity's UnitySendMessage from Plugins/iOS/Battery.m as + // UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", ""). + // Renaming this method or its enclosing GameObject requires updating the iOS .m file. + // ReSharper disable once UnusedMember.Global + // ReSharper disable once InconsistentNaming + public void OnIosLowPowerModeChanged(string _) + { + _onIosLowPowerModeChanged?.Invoke(); + } + + private void LateUpdate() + { + _onLateUpdate?.Invoke(); + + _secondAccumulator += Time.unscaledDeltaTime; + if (_secondAccumulator >= 1f) + { + _secondAccumulator = 0f; + _onSecondTick?.Invoke(); + } + } + + private void OnApplicationFocus(bool focused) + { + _onApplicationFocusChanged?.Invoke(focused); + } + + private void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + } + } +} diff --git a/Runtime/Device/Internal/DeviceServicesHost.cs.meta b/Runtime/Device/Internal/DeviceServicesHost.cs.meta new file mode 100644 index 0000000..12f7983 --- /dev/null +++ b/Runtime/Device/Internal/DeviceServicesHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f7ad0af7b8cf45eca36a4853ba2cce3 \ No newline at end of file diff --git a/Runtime/Device/Permissions.meta b/Runtime/Device/Permissions.meta new file mode 100644 index 0000000..1ecd1b6 --- /dev/null +++ b/Runtime/Device/Permissions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e7031fd4089a9460b9883ce24bc5d668 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Permissions/IPermissionsService.cs b/Runtime/Device/Permissions/IPermissionsService.cs new file mode 100644 index 0000000..e97a6aa --- /dev/null +++ b/Runtime/Device/Permissions/IPermissionsService.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Cross-platform permissions catalog. + /// + public enum AppPermission + { + Camera, + Microphone, + LocationWhenInUse, + LocationAlways, + PhotoLibrary, + PhotoLibraryAddOnly, + Notifications, + } + + /// Result of a permission check / request. + public enum PermissionStatus + { + NotDetermined, + Denied, + Granted, + Restricted, + } + + /// + /// Unified iOS+Android runtime-permissions service. + /// + /// + /// Uses rather than UniTask to avoid pulling in a new package dependency. + /// + public interface IPermissionsService + { + /// Returns the current status without prompting the user. Synchronous. + PermissionStatus Check(AppPermission permission); + + /// Requests the permission, prompting the user if not yet determined. Idempotent if already granted/denied. + Task RequestAsync(AppPermission permission); + } +} diff --git a/Runtime/Device/Permissions/IPermissionsService.cs.meta b/Runtime/Device/Permissions/IPermissionsService.cs.meta new file mode 100644 index 0000000..4b8dd46 --- /dev/null +++ b/Runtime/Device/Permissions/IPermissionsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42aa64b32bcbc479c819cae6e6505f36 \ No newline at end of file diff --git a/Runtime/Device/Permissions/Internal.meta b/Runtime/Device/Permissions/Internal.meta new file mode 100644 index 0000000..0065360 --- /dev/null +++ b/Runtime/Device/Permissions/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8a029d609b7264f51be90ed20f6c91ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs new file mode 100644 index 0000000..8365395 --- /dev/null +++ b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device.Internal +{ + /// + /// Internal MonoBehaviour that receives async permission results from the iOS native bridge via + /// UnitySendMessage. Resolves the matching pending . + /// + internal sealed class PermissionsCallbackReceiver : MonoBehaviour + { + private static PermissionsCallbackReceiver _instance; + private readonly Dictionary> _pending = + new Dictionary>(); + private int _nextId = 1; + + public static PermissionsCallbackReceiver Instance + { + get + { + if (_instance != null) + { + return _instance; + } + + var go = new GameObject("PermissionsCallbackReceiver"); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + return _instance; + } + } + + /// Registers a TCS and returns the request id to pass to the native bridge. + public int Register(TaskCompletionSource tcs) + { + var id = _nextId++; + _pending[id] = tcs; + return id; + } + + // Native iOS bridge calls UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", ":") + // where status is the int value of PermissionStatus. + // ReSharper disable once UnusedMember.Global + // ReSharper disable once InconsistentNaming + public void OnPermissionResult(string payload) + { + try + { + var sep = payload.IndexOf(':'); + if (sep <= 0) + { + return; + } + var idText = payload.Substring(0, sep); + var statusText = payload.Substring(sep + 1); + + if (!int.TryParse(idText, out var id) || + !int.TryParse(statusText, out var statusInt)) + { + return; + } + + if (!_pending.TryGetValue(id, out var tcs)) + { + return; + } + + _pending.Remove(id); + + var status = (PermissionStatus)statusInt; + tcs.TrySetResult(status); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] PermissionsCallbackReceiver failed to parse '{payload}': {e.Message}"); + } + } + + private void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + + foreach (var tcs in _pending.Values) + { + tcs.TrySetCanceled(); + } + _pending.Clear(); + } + } +} diff --git a/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta new file mode 100644 index 0000000..deaabd1 --- /dev/null +++ b/Runtime/Device/Permissions/Internal/PermissionsCallbackReceiver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d72e80e5de72440cbb3195cc5afd331a \ No newline at end of file diff --git a/Runtime/Device/Permissions/PermissionsService.cs b/Runtime/Device/Permissions/PermissionsService.cs new file mode 100644 index 0000000..709cc0a --- /dev/null +++ b/Runtime/Device/Permissions/PermissionsService.cs @@ -0,0 +1,105 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using GameLovers.MobileServices.Device.Internal; +using UnityEngine; + +#if UNITY_ANDROID && !UNITY_EDITOR +using UnityEngine.Android; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class PermissionsService : IPermissionsService + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] private static extern int _GameLoversPermissionsCheck(int permissionId); + [DllImport("__Internal")] private static extern void _GameLoversPermissionsRequest(int permissionId, int requestId, string callbackGameObject, string callbackMethod); +#endif + + /// + public PermissionStatus Check(AppPermission permission) + { +#if UNITY_EDITOR + return PermissionStatus.Granted; +#elif UNITY_IOS + return (PermissionStatus)_GameLoversPermissionsCheck((int)permission); +#elif UNITY_ANDROID + return CheckAndroid(permission); +#else + return PermissionStatus.NotDetermined; +#endif + } + + /// + public Task RequestAsync(AppPermission permission) + { +#if UNITY_EDITOR + return Task.FromResult(PermissionStatus.Granted); +#elif UNITY_IOS + var tcs = new TaskCompletionSource(); + var id = PermissionsCallbackReceiver.Instance.Register(tcs); + _GameLoversPermissionsRequest((int)permission, id, "PermissionsCallbackReceiver", "OnPermissionResult"); + return tcs.Task; +#elif UNITY_ANDROID + return RequestAndroidAsync(permission); +#else + return Task.FromResult(PermissionStatus.NotDetermined); +#endif + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static string AndroidManifestPermission(AppPermission permission) + { + return permission switch + { + AppPermission.Camera => Permission.Camera, + AppPermission.Microphone => Permission.Microphone, + AppPermission.LocationWhenInUse => Permission.FineLocation, + AppPermission.LocationAlways => Permission.FineLocation, + AppPermission.PhotoLibrary => "android.permission.READ_MEDIA_IMAGES", + AppPermission.PhotoLibraryAddOnly => "android.permission.READ_MEDIA_IMAGES", + AppPermission.Notifications => "android.permission.POST_NOTIFICATIONS", + _ => null + }; + } + + private static PermissionStatus CheckAndroid(AppPermission permission) + { + var manifestId = AndroidManifestPermission(permission); + if (string.IsNullOrEmpty(manifestId)) + { + return PermissionStatus.NotDetermined; + } + return Permission.HasUserAuthorizedPermission(manifestId) + ? PermissionStatus.Granted + : PermissionStatus.NotDetermined; + } + + private static Task RequestAndroidAsync(AppPermission permission) + { + var manifestId = AndroidManifestPermission(permission); + if (string.IsNullOrEmpty(manifestId)) + { + return Task.FromResult(PermissionStatus.NotDetermined); + } + + if (Permission.HasUserAuthorizedPermission(manifestId)) + { + return Task.FromResult(PermissionStatus.Granted); + } + + var tcs = new TaskCompletionSource(); + var callbacks = new PermissionCallbacks(); + callbacks.PermissionGranted += _ => tcs.TrySetResult(PermissionStatus.Granted); + callbacks.PermissionDenied += _ => tcs.TrySetResult(PermissionStatus.Denied); + callbacks.PermissionDeniedAndDontAskAgain += _ => tcs.TrySetResult(PermissionStatus.Denied); + + Permission.RequestUserPermission(manifestId, callbacks); + return tcs.Task; + } +#endif + } +} diff --git a/Runtime/Device/Permissions/PermissionsService.cs.meta b/Runtime/Device/Permissions/PermissionsService.cs.meta new file mode 100644 index 0000000..078d1a7 --- /dev/null +++ b/Runtime/Device/Permissions/PermissionsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f1dbb6576c374171a4bd1ece4171526 \ No newline at end of file diff --git a/Runtime/Device/State.meta b/Runtime/Device/State.meta new file mode 100644 index 0000000..ea29c41 --- /dev/null +++ b/Runtime/Device/State.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c310f548e14f146aaa345db1faebc5e2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/State/BatteryService.cs b/Runtime/Device/State/BatteryService.cs new file mode 100644 index 0000000..be52872 --- /dev/null +++ b/Runtime/Device/State/BatteryService.cs @@ -0,0 +1,149 @@ +using System; +using System.Runtime.InteropServices; +using GameLovers.MobileServices.Device.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class BatteryService : IBatteryService, IDisposable + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] private static extern bool _GameLoversBatteryIsLowPowerModeEnabled(); + [DllImport("__Internal")] private static extern void _GameLoversBatteryStartObservingLowPowerMode(); + [DllImport("__Internal")] private static extern void _GameLoversBatteryStopObservingLowPowerMode(); +#endif + + private const float LevelChangeThreshold = 0.01f; + + private readonly DeviceServicesHost _host; + + private float _lastLevel; + private BatteryStatus _lastStatus; + private bool _lastLowPowerMode; + + /// + public float Level => SystemInfo.batteryLevel; + + /// + public BatteryStatus Status => SystemInfo.batteryStatus; + + /// + public bool IsLowPowerMode { get; private set; } + + /// + public event Action OnLevelChanged; + /// + public event Action OnStatusChanged; + /// + public event Action OnLowPowerModeChanged; + + /// Default ctor uses the package-wide singleton host (). + public BatteryService() : this(DeviceServicesHost.Instance) { } + + /// + /// Test/DI overload that accepts an explicit host. Used by to + /// share a single host instance across the umbrella's children, and by tests that want + /// deterministic host lifetime. + /// + internal BatteryService(DeviceServicesHost host) + { + _host = host; + _lastLevel = Level; + _lastStatus = Status; + IsLowPowerMode = QueryLowPowerMode(); + _lastLowPowerMode = IsLowPowerMode; + + _host.RegisterSecondTick(OnSecondTick); + _host.RegisterFocusChanged(OnFocusChanged); + _host.RegisterIosLowPowerModeChanged(OnIosLowPowerModeChanged); + +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversBatteryStartObservingLowPowerMode(); +#endif + } + + public void Dispose() + { + _host.UnregisterSecondTick(OnSecondTick); + _host.UnregisterFocusChanged(OnFocusChanged); + _host.UnregisterIosLowPowerModeChanged(OnIosLowPowerModeChanged); + +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversBatteryStopObservingLowPowerMode(); +#endif + } + + private void OnSecondTick() + { + var current = Level; + if (Mathf.Abs(current - _lastLevel) >= LevelChangeThreshold) + { + _lastLevel = current; + OnLevelChanged?.Invoke(); + } + + var status = Status; + if (status != _lastStatus) + { + _lastStatus = status; + OnStatusChanged?.Invoke(); + } + } + + private void OnFocusChanged(bool focused) + { + if (!focused) + { + return; + } + RefreshLowPowerMode(); + } + + private void OnIosLowPowerModeChanged() + { + RefreshLowPowerMode(); + } + + private void RefreshLowPowerMode() + { + var current = QueryLowPowerMode(); + if (current == _lastLowPowerMode) + { + return; + } + _lastLowPowerMode = current; + IsLowPowerMode = current; + OnLowPowerModeChanged?.Invoke(); + } + + private static bool QueryLowPowerMode() + { +#if UNITY_IOS && !UNITY_EDITOR + try + { + return _GameLoversBatteryIsLowPowerModeEnabled(); + } + catch + { + return false; + } +#elif UNITY_ANDROID && !UNITY_EDITOR + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var powerManager = activity.Call("getSystemService", "power"); + return powerManager.Call("isPowerSaveMode"); + } + catch + { + return false; + } +#else + return false; +#endif + } + } +} diff --git a/Runtime/Device/State/BatteryService.cs.meta b/Runtime/Device/State/BatteryService.cs.meta new file mode 100644 index 0000000..08b1256 --- /dev/null +++ b/Runtime/Device/State/BatteryService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 533e48499815748e5b47d09fd3cccff6 \ No newline at end of file diff --git a/Runtime/Device/State/ConnectivityService.cs b/Runtime/Device/State/ConnectivityService.cs new file mode 100644 index 0000000..e2246cc --- /dev/null +++ b/Runtime/Device/State/ConnectivityService.cs @@ -0,0 +1,62 @@ +using System; +using GameLovers.MobileServices.Device.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class ConnectivityService : IConnectivityService, IDisposable + { + private readonly DeviceServicesHost _host; + + private NetworkReachability _lastStatus; + + /// + public NetworkReachability Status => Application.internetReachability; + + /// + public event Action OnStatusChanged; + + /// Default ctor uses the package-wide singleton host (). + public ConnectivityService() : this(DeviceServicesHost.Instance) { } + + /// + /// Test/DI overload that accepts an explicit host. Used by to + /// share a single host instance across the umbrella's children, and by tests that want + /// deterministic host lifetime. + /// + internal ConnectivityService(DeviceServicesHost host) + { + _host = host; + _lastStatus = Status; + _host.RegisterSecondTick(Tick); + _host.RegisterFocusChanged(OnFocusChanged); + } + + public void Dispose() + { + _host.UnregisterSecondTick(Tick); + _host.UnregisterFocusChanged(OnFocusChanged); + } + + private void OnFocusChanged(bool focused) + { + if (focused) + { + Tick(); + } + } + + private void Tick() + { + var current = Status; + if (current == _lastStatus) + { + return; + } + _lastStatus = current; + OnStatusChanged?.Invoke(current); + } + } +} diff --git a/Runtime/Device/State/ConnectivityService.cs.meta b/Runtime/Device/State/ConnectivityService.cs.meta new file mode 100644 index 0000000..55505a5 --- /dev/null +++ b/Runtime/Device/State/ConnectivityService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b0628494db8cf437bb45a5630ad6f881 \ No newline at end of file diff --git a/Runtime/Device/State/IBatteryService.cs b/Runtime/Device/State/IBatteryService.cs new file mode 100644 index 0000000..e106fbe --- /dev/null +++ b/Runtime/Device/State/IBatteryService.cs @@ -0,0 +1,31 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Wraps Unity's SystemInfo.batteryLevel / SystemInfo.batteryStatus with change events, + /// plus iOS / Android low-power-mode awareness. + /// + public interface IBatteryService + { + /// Current battery charge in [0, 1]; -1 if unknown. + float Level { get; } + + /// Current charging status (Charging, Discharging, NotCharging, Full, Unknown). + BatteryStatus Status { get; } + + /// True when the OS reports its low-power / battery-saver mode is active. + bool IsLowPowerMode { get; } + + /// Fired when changes by more than ~1%. + event Action OnLevelChanged; + + /// Fired when transitions. + event Action OnStatusChanged; + + /// Fired when transitions. + event Action OnLowPowerModeChanged; + } +} diff --git a/Runtime/Device/State/IBatteryService.cs.meta b/Runtime/Device/State/IBatteryService.cs.meta new file mode 100644 index 0000000..cb1f34f --- /dev/null +++ b/Runtime/Device/State/IBatteryService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ea9e7ecb85e643fdb526cb7e421b78a \ No newline at end of file diff --git a/Runtime/Device/State/IConnectivityService.cs b/Runtime/Device/State/IConnectivityService.cs new file mode 100644 index 0000000..414c01e --- /dev/null +++ b/Runtime/Device/State/IConnectivityService.cs @@ -0,0 +1,20 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Best-effort wrapper around Application.internetReachability with change events. + /// "Best effort" because internetReachability only reports interface state, not actual + /// internet access — for hard guarantees, hit a real endpoint. + /// + public interface IConnectivityService + { + /// Latest known reachability snapshot. + NetworkReachability Status { get; } + + /// Fired when transitions. + event Action OnStatusChanged; + } +} diff --git a/Runtime/Device/State/IConnectivityService.cs.meta b/Runtime/Device/State/IConnectivityService.cs.meta new file mode 100644 index 0000000..9c602f9 --- /dev/null +++ b/Runtime/Device/State/IConnectivityService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fdea870b570d447c4b898abcdbc32dc7 \ No newline at end of file diff --git a/Runtime/Device/State/ISafeAreaService.cs b/Runtime/Device/State/ISafeAreaService.cs new file mode 100644 index 0000000..ee726f2 --- /dev/null +++ b/Runtime/Device/State/ISafeAreaService.cs @@ -0,0 +1,19 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Wraps Screen.safeArea with change events for orientation, notch / dynamic-island + /// reveal, and any other runtime safe-area shifts. Fires on diff only. + /// + public interface ISafeAreaService + { + /// Latest known safe area in screen pixels (cached Screen.safeArea). + Rect SafeArea { get; } + + /// Fired when the safe area changes (orientation, notch reveal, etc.). + event Action OnSafeAreaChanged; + } +} diff --git a/Runtime/Device/State/ISafeAreaService.cs.meta b/Runtime/Device/State/ISafeAreaService.cs.meta new file mode 100644 index 0000000..133933e --- /dev/null +++ b/Runtime/Device/State/ISafeAreaService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 322efff88ac064790a260aaaea5a4501 \ No newline at end of file diff --git a/Runtime/Device/State/IScreenWakeService.cs b/Runtime/Device/State/IScreenWakeService.cs new file mode 100644 index 0000000..d5429c6 --- /dev/null +++ b/Runtime/Device/State/IScreenWakeService.cs @@ -0,0 +1,15 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Controls whether the device screen should stay awake (override the OS sleep timeout). + /// + public interface IScreenWakeService + { + /// + /// When true, sets Screen.sleepTimeout to SleepTimeout.NeverSleep; + /// when false, restores SleepTimeout.SystemSetting. Idempotent. + /// + bool KeepAwake { get; set; } + } +} diff --git a/Runtime/Device/State/IScreenWakeService.cs.meta b/Runtime/Device/State/IScreenWakeService.cs.meta new file mode 100644 index 0000000..08650cf --- /dev/null +++ b/Runtime/Device/State/IScreenWakeService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bcda1d4b4808e4808ba9b6fd7e980d99 \ No newline at end of file diff --git a/Runtime/Device/State/SafeAreaContainer.cs b/Runtime/Device/State/SafeAreaContainer.cs new file mode 100644 index 0000000..98b44ad --- /dev/null +++ b/Runtime/Device/State/SafeAreaContainer.cs @@ -0,0 +1,77 @@ +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// UI Toolkit container that automatically pads its content to respect the device safe area. + /// Subscribe via the constructor that accepts an ; the container + /// updates its own padding whenever the safe area changes. + /// + public sealed class SafeAreaContainer : VisualElement + { + private ISafeAreaService _safeAreaService; + + /// UXML factory for use in UXML documents (uses a runtime-provided service via panel data). + public new class UxmlFactory : UxmlFactory { } + + /// Default constructor for UXML usage. Set the service via . + public SafeAreaContainer() + { + RegisterCallback(_ => Apply()); + } + + /// Code-construction with the service injected. + public SafeAreaContainer(ISafeAreaService safeAreaService) : this() + { + SetSafeAreaService(safeAreaService); + } + + /// Wires the container to the supplied service and applies the current safe area immediately. + public void SetSafeAreaService(ISafeAreaService safeAreaService) + { + if (_safeAreaService != null) + { + _safeAreaService.OnSafeAreaChanged -= OnSafeAreaChanged; + } + + _safeAreaService = safeAreaService; + + if (_safeAreaService == null) + { + return; + } + + _safeAreaService.OnSafeAreaChanged += OnSafeAreaChanged; + Apply(); + } + + private void OnSafeAreaChanged(Rect _) + { + Apply(); + } + + private void Apply() + { + var safeArea = _safeAreaService?.SafeArea ?? Screen.safeArea; + var screenWidth = Screen.width; + var screenHeight = Screen.height; + if (screenWidth <= 0 || screenHeight <= 0) + { + return; + } + + // Convert from screen pixels to UI Toolkit padding (top-left origin). + var left = safeArea.xMin; + var right = screenWidth - safeArea.xMax; + var top = screenHeight - safeArea.yMax; + var bottom = safeArea.yMin; + + style.paddingLeft = left; + style.paddingRight = right; + style.paddingTop = top; + style.paddingBottom = bottom; + } + } +} diff --git a/Runtime/Device/State/SafeAreaContainer.cs.meta b/Runtime/Device/State/SafeAreaContainer.cs.meta new file mode 100644 index 0000000..060ef75 --- /dev/null +++ b/Runtime/Device/State/SafeAreaContainer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e6962b99fd39d479fbd1d52b08f5b7ed \ No newline at end of file diff --git a/Runtime/Device/State/SafeAreaService.cs b/Runtime/Device/State/SafeAreaService.cs new file mode 100644 index 0000000..9e9a753 --- /dev/null +++ b/Runtime/Device/State/SafeAreaService.cs @@ -0,0 +1,58 @@ +using System; +using GameLovers.MobileServices.Device.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class SafeAreaService : ISafeAreaService, IDisposable + { + private readonly DeviceServicesHost _host; + + private Rect _lastSafeArea; + private Vector2Int _lastResolution; + + /// + public Rect SafeArea => _lastSafeArea; + + /// + public event Action OnSafeAreaChanged; + + /// Default ctor uses the package-wide singleton host (). + public SafeAreaService() : this(DeviceServicesHost.Instance) { } + + /// + /// Test/DI overload that accepts an explicit host. Used by to + /// share a single host instance across the umbrella's children, and by tests that want + /// deterministic host lifetime. + /// + internal SafeAreaService(DeviceServicesHost host) + { + _host = host; + _lastSafeArea = Screen.safeArea; + _lastResolution = new Vector2Int(Screen.width, Screen.height); + _host.RegisterLateUpdate(Tick); + } + + public void Dispose() + { + _host.UnregisterLateUpdate(Tick); + } + + private void Tick() + { + var current = Screen.safeArea; + var resolution = new Vector2Int(Screen.width, Screen.height); + + if (current == _lastSafeArea && resolution == _lastResolution) + { + return; + } + + _lastSafeArea = current; + _lastResolution = resolution; + OnSafeAreaChanged?.Invoke(current); + } + } +} diff --git a/Runtime/Device/State/SafeAreaService.cs.meta b/Runtime/Device/State/SafeAreaService.cs.meta new file mode 100644 index 0000000..832b65f --- /dev/null +++ b/Runtime/Device/State/SafeAreaService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72d96de654ed341f78d86327d9eb3270 \ No newline at end of file diff --git a/Runtime/Device/State/ScreenWakeService.cs b/Runtime/Device/State/ScreenWakeService.cs new file mode 100644 index 0000000..e6ed370 --- /dev/null +++ b/Runtime/Device/State/ScreenWakeService.cs @@ -0,0 +1,16 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class ScreenWakeService : IScreenWakeService + { + /// + public bool KeepAwake + { + get => Screen.sleepTimeout == SleepTimeout.NeverSleep; + set => Screen.sleepTimeout = value ? SleepTimeout.NeverSleep : SleepTimeout.SystemSetting; + } + } +} diff --git a/Runtime/Device/State/ScreenWakeService.cs.meta b/Runtime/Device/State/ScreenWakeService.cs.meta new file mode 100644 index 0000000..88e2b1b --- /dev/null +++ b/Runtime/Device/State/ScreenWakeService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f4ef98007e9d54bd2acaddde0c165c80 \ No newline at end of file diff --git a/Runtime/Device/Tracking.meta b/Runtime/Device/Tracking.meta new file mode 100644 index 0000000..efafc1c --- /dev/null +++ b/Runtime/Device/Tracking.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e0a43e62f98464b69a662cec5d41bafd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Tracking/AttService.cs b/Runtime/Device/Tracking/AttService.cs new file mode 100644 index 0000000..bccc3cc --- /dev/null +++ b/Runtime/Device/Tracking/AttService.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using GameLovers.MobileServices.Device.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class AttService : IAttService + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] private static extern int _GameLoversAttCurrentStatus(); + [DllImport("__Internal")] private static extern void _GameLoversAttRequestAuthorization(int requestId, string callbackGameObject, string callbackMethod); +#endif + + /// + public AttStatus CurrentStatus + { + get + { +#if UNITY_IOS && !UNITY_EDITOR + return (AttStatus)_GameLoversAttCurrentStatus(); +#else + return AttStatus.Authorized; +#endif + } + } + + /// + public Task RequestAuthorizationAsync() + { +#if UNITY_IOS && !UNITY_EDITOR + var tcs = new TaskCompletionSource(); + var id = AttCallbackReceiver.Instance.Register(tcs); + _GameLoversAttRequestAuthorization(id, "AttCallbackReceiver", "OnAttResult"); + return tcs.Task; +#else + return Task.FromResult(AttStatus.Authorized); +#endif + } + } +} + +namespace GameLovers.MobileServices.Device.Internal +{ + /// + /// Internal MonoBehaviour that receives ATT results from the iOS bridge via UnitySendMessage. + /// Mirrors the shape of so each subsystem owns its own + /// payload format and we don't have to multiplex. + /// + internal sealed class AttCallbackReceiver : MonoBehaviour + { + private static AttCallbackReceiver _instance; + private readonly Dictionary> _pending = + new Dictionary>(); + private int _nextId = 1; + + public static AttCallbackReceiver Instance + { + get + { + if (_instance != null) + { + return _instance; + } + + var go = new GameObject("AttCallbackReceiver"); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + return _instance; + } + } + + public int Register(TaskCompletionSource tcs) + { + var id = _nextId++; + _pending[id] = tcs; + return id; + } + + // Native iOS bridge calls UnitySendMessage("AttCallbackReceiver", "OnAttResult", ":") + // where status is the int value of AttStatus. + // ReSharper disable once UnusedMember.Global + // ReSharper disable once InconsistentNaming + public void OnAttResult(string payload) + { + try + { + var sep = payload.IndexOf(':'); + if (sep <= 0) return; + var idText = payload.Substring(0, sep); + var statusText = payload.Substring(sep + 1); + + if (!int.TryParse(idText, out var id) || !int.TryParse(statusText, out var statusInt)) + { + return; + } + + if (!_pending.TryGetValue(id, out var tcs)) + { + return; + } + + _pending.Remove(id); + tcs.TrySetResult((AttStatus)statusInt); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] AttCallbackReceiver failed to parse '{payload}': {e.Message}"); + } + } + + private void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + foreach (var tcs in _pending.Values) + { + tcs.TrySetCanceled(); + } + _pending.Clear(); + } + } +} diff --git a/Runtime/Device/Tracking/AttService.cs.meta b/Runtime/Device/Tracking/AttService.cs.meta new file mode 100644 index 0000000..c6dd7bf --- /dev/null +++ b/Runtime/Device/Tracking/AttService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bdbb8a0b6ac74dd3989c02d54238c5b \ No newline at end of file diff --git a/Runtime/Device/Tracking/IAttService.cs b/Runtime/Device/Tracking/IAttService.cs new file mode 100644 index 0000000..8ed2672 --- /dev/null +++ b/Runtime/Device/Tracking/IAttService.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// App Tracking Transparency authorization status (mirrors iOS ATTrackingManagerAuthorizationStatus). + public enum AttStatus + { + NotDetermined = 0, + Restricted = 1, + Denied = 2, + Authorized = 3, + } + + /// + /// iOS 14.5+ App Tracking Transparency. Android / Editor / unsupported platforms always return + /// (no equivalent restriction). + /// + /// + /// Built directly on ATTrackingManager with no dependency on the deprecation-bound + /// com.unity.ads.ios-support package. + /// + public interface IAttService + { + /// Current authorization status without prompting. + AttStatus CurrentStatus { get; } + + /// + /// Requests tracking authorization. Idempotent: if the user has already responded (granted, + /// denied, or restricted) the OS returns the previous decision without showing the prompt again. + /// + Task RequestAuthorizationAsync(); + } +} diff --git a/Runtime/Device/Tracking/IAttService.cs.meta b/Runtime/Device/Tracking/IAttService.cs.meta new file mode 100644 index 0000000..d44a8b8 --- /dev/null +++ b/Runtime/Device/Tracking/IAttService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e5f5ad2ead7f40069765b00f21f19cf \ No newline at end of file diff --git a/Runtime/Haptics.meta b/Runtime/Haptics.meta new file mode 100644 index 0000000..eb0d3d1 --- /dev/null +++ b/Runtime/Haptics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: de2f36c03a9d942e597b675c04c65cd1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Haptics/HapticPreset.cs b/Runtime/Haptics/HapticPreset.cs new file mode 100644 index 0000000..bd2abe6 --- /dev/null +++ b/Runtime/Haptics/HapticPreset.cs @@ -0,0 +1,40 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics +{ + /// + /// Catalogue of cross-platform haptic feedback presets. + /// Each preset maps to platform-native primitives (iOS UIFeedbackGenerator family / Android VibrationEffect waveform). + /// + public enum HapticPreset + { + /// No haptic. Calls are short-circuited. + None = 0, + + /// Crisp tick suitable for picker / discrete value changes. + Selection = 1, + + /// Two-tap success notification (ascending). + Success = 2, + + /// Single warning notification. + Warning = 3, + + /// Multi-tap error notification. + Error = 4, + + /// Soft, low-amplitude impact. + ImpactLight = 5, + + /// Default impact strength. + ImpactMedium = 6, + + /// Strong impact for major hits. + ImpactHeavy = 7, + + /// Sharp, short impact (snappy). + ImpactRigid = 8, + + /// Gentle, longer impact (cushioned). + ImpactSoft = 9, + } +} diff --git a/Runtime/Haptics/HapticPreset.cs.meta b/Runtime/Haptics/HapticPreset.cs.meta new file mode 100644 index 0000000..7898c5e --- /dev/null +++ b/Runtime/Haptics/HapticPreset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 105f8a400aa0b4d60bed6e7f2919c921 \ No newline at end of file diff --git a/Runtime/Haptics/HapticsService.cs b/Runtime/Haptics/HapticsService.cs new file mode 100644 index 0000000..d7bbbeb --- /dev/null +++ b/Runtime/Haptics/HapticsService.cs @@ -0,0 +1,155 @@ +using GameLovers.MobileServices.Haptics.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics +{ + /// + public sealed class HapticsService : IHapticsService + { + private readonly IHapticsBackend _backend; + + // HapticsHost MonoBehaviour is spawned lazily on the first Play* call so this service can + // safely be constructed during DI bootstrap (before any GameObject scenes exist). + private HapticsHost _host; + + private bool _enabled = true; + private bool _isPlaying; + + public HapticsService() : this(CreateDefaultBackend()) { } + + internal HapticsService(IHapticsBackend backend) + { + _backend = backend; + } + + /// + public bool Enabled + { + get => _enabled; + set + { + if (_enabled == value) + { + return; + } + _enabled = value; + if (!_enabled) + { + StopCurrentHaptic(); + } + } + } + + /// + public bool IsSupported => _backend.IsSupported; + + /// + public bool IsPlaying => _isPlaying; + + /// + public void PlayPreset(HapticPreset preset) + { + PlayPresetDuration(preset, 0f); + } + + /// + public void PlayPresetDuration(HapticPreset preset, float duration = -1f) + { + if (!_enabled || preset == HapticPreset.None) + { + return; + } + + CancelPendingAutoStop(); + + if (duration == 0f) + { + _backend.PlayPresetOneShot(preset); + _isPlaying = true; + return; + } + + _backend.PlayPresetLoop(preset); + _isPlaying = true; + + if (duration > 0f) + { + EnsureHost().ScheduleStop(duration, OnAutoStop); + } + } + + /// + public void PlayCustom(float intensity01, float durationMs) + { + if (!_enabled || durationMs <= 0f) + { + return; + } + + CancelPendingAutoStop(); + + intensity01 = Mathf.Clamp01(intensity01); + _backend.PlayCustom(intensity01, durationMs); + _isPlaying = true; + + EnsureHost().ScheduleStop(durationMs / 1000f, OnAutoStop); + } + + /// + public void StopCurrentHaptic() + { + CancelPendingAutoStop(); + if (!_isPlaying) + { + return; + } + _backend.Stop(); + _isPlaying = false; + } + + private void OnAutoStop() + { + if (!_isPlaying) + { + return; + } + _backend.Stop(); + _isPlaying = false; + } + + private void CancelPendingAutoStop() + { + if (_host != null) + { + _host.Cancel(); + } + } + + private HapticsHost EnsureHost() + { + if (_host != null) + { + return _host; + } + + var go = new GameObject("HapticsHost"); + Object.DontDestroyOnLoad(go); + _host = go.AddComponent(); + return _host; + } + + private static IHapticsBackend CreateDefaultBackend() + { +#if UNITY_EDITOR + return new EditorHapticsBackend(); +#elif UNITY_IOS + return new IosHapticsBackend(); +#elif UNITY_ANDROID + return new AndroidHapticsBackend(); +#else + return new NoOpHapticsBackend(); +#endif + } + } +} diff --git a/Runtime/Haptics/HapticsService.cs.meta b/Runtime/Haptics/HapticsService.cs.meta new file mode 100644 index 0000000..d3a5a51 --- /dev/null +++ b/Runtime/Haptics/HapticsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fcee7f80153054a108da13c0e904968f \ No newline at end of file diff --git a/Runtime/Haptics/IHapticsService.cs b/Runtime/Haptics/IHapticsService.cs new file mode 100644 index 0000000..19b9026 --- /dev/null +++ b/Runtime/Haptics/IHapticsService.cs @@ -0,0 +1,60 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics +{ + /// + /// Cross-platform haptic feedback. Built directly on iOS UI*FeedbackGenerator (iOS 10+) and + /// Android VibrationEffect.createWaveform (API 26+) — no third-party plugin required. + /// On Editor / unsupported platforms every call is a safe no-op. + /// + public interface IHapticsService + { + /// + /// Master toggle. When false, every Play* call returns immediately without + /// touching native. Setting this to false while a haptic is active also calls + /// internally. + /// + bool Enabled { get; set; } + + /// + /// True when the device can do at least basic vibration (Android: SystemInfo.supportsVibration; + /// iOS: device family supports UIFeedbackGenerator; otherwise false). + /// + bool IsSupported { get; } + + /// + /// True between any Play* call and the matching stop (auto or manual). + /// + bool IsPlaying { get; } + + /// + /// Plays a one-shot preset using its natural duration. Convenience for + /// with duration = 0f. + /// + void PlayPreset(HapticPreset preset); + + /// + /// Plays a preset with explicit duration semantics: + /// + /// duration == 0f — play the preset's natural one-shot duration. + /// duration < 0f (default -1f) — loop indefinitely. Caller MUST + /// invoke to end it. + /// duration > 0f — loop the preset and auto-stop after + /// real-time seconds (unaffected by Time.timeScale). + /// + /// + void PlayPresetDuration(HapticPreset preset, float duration = -1f); + + /// + /// Plays a single custom-intensity haptic and auto-stops after . + /// is clamped to [0, 1]. + /// + void PlayCustom(float intensity01, float durationMs); + + /// + /// Stops any active haptic immediately, regardless of which Play* started it. + /// Safe to call when nothing is playing (no-op). Also cancels any pending auto-stop scheduled + /// by with duration > 0f or by . + /// + void StopCurrentHaptic(); + } +} diff --git a/Runtime/Haptics/IHapticsService.cs.meta b/Runtime/Haptics/IHapticsService.cs.meta new file mode 100644 index 0000000..54b493d --- /dev/null +++ b/Runtime/Haptics/IHapticsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3817f6dea02794512a17b6aba1e104ec \ No newline at end of file diff --git a/Runtime/Haptics/Internal.meta b/Runtime/Haptics/Internal.meta new file mode 100644 index 0000000..c4b29f0 --- /dev/null +++ b/Runtime/Haptics/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6acccd3e4a2a4ead9dd10ef0458fcf5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs new file mode 100644 index 0000000..2347144 --- /dev/null +++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs @@ -0,0 +1,211 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using UnityEngine; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Android implementation built on android.os.Vibrator.vibrate(VibrationEffect) via JNI. + /// Uses VibrationEffect.createWaveform(long[] timings, int[] amplitudes, int repeat) for + /// preset playback. Preset envelopes were translated from the Lofelt time/amplitude pairs + /// used by the demons reference; every line of code here is original. + /// Requires API level 26 (Android 8.0) or higher. + /// + internal sealed class AndroidHapticsBackend : IHapticsBackend + { +#if UNITY_ANDROID && !UNITY_EDITOR + private const int RepeatLoop = 0; + private const int RepeatNone = -1; + private const int DefaultAmplitude = -1; // VibrationEffect.DEFAULT_AMPLITUDE + + private AndroidJavaObject _vibrator; + private AndroidJavaClass _vibrationEffectClass; + private bool _initialized; +#endif + + /// + public bool IsSupported + { + get + { +#if UNITY_ANDROID && !UNITY_EDITOR + return SystemInfo.supportsVibration; +#else + return false; +#endif + } + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private bool EnsureInitialized() + { + if (_initialized) + { + return _vibrator != null; + } + + _initialized = true; + + try + { + using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = unityPlayer.GetStatic("currentActivity")) + { + _vibrator = activity.Call("getSystemService", "vibrator"); + } + + _vibrationEffectClass = new AndroidJavaClass("android.os.VibrationEffect"); + } + catch (System.Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Haptics init failed: {e.Message}"); + _vibrator = null; + _vibrationEffectClass = null; + } + + return _vibrator != null && _vibrationEffectClass != null; + } + + private static (long[] timingsMs, int[] amplitudes) GetEnvelopeFor(HapticPreset preset) + { + // Time/amplitude pairs are in seconds and [0,1] amplitude (matching Lofelt's HapticPatterns + // shape). Translated to (long[] millis, int[] 0..255 amplitudes) for VibrationEffect. + float[] timesSec; float[] amps; + switch (preset) + { + case HapticPreset.Selection: + timesSec = new[] { 0.04f }; + amps = new[] { 0.471f }; + break; + case HapticPreset.Success: + timesSec = new[] { 0.04f, 0.04f, 0.16f }; + amps = new[] { 0.157f, 0.0f, 1.000f }; + break; + case HapticPreset.Warning: + timesSec = new[] { 0.12f, 0.12f, 0.04f }; + amps = new[] { 1.000f, 0.0f, 0.470f }; + break; + case HapticPreset.Error: + timesSec = new[] { 0.08f, 0.04f, 0.08f, 0.04f, 0.16f, 0.04f, 0.04f }; + amps = new[] { 0.470f, 0.0f, 0.470f, 0.0f, 1.000f, 0.0f, 0.157f }; + break; + case HapticPreset.ImpactLight: + timesSec = new[] { 0.04f }; + amps = new[] { 0.156f }; + break; + case HapticPreset.ImpactMedium: + timesSec = new[] { 0.08f }; + amps = new[] { 0.471f }; + break; + case HapticPreset.ImpactHeavy: + timesSec = new[] { 0.16f }; + amps = new[] { 1.000f }; + break; + case HapticPreset.ImpactRigid: + timesSec = new[] { 0.04f }; + amps = new[] { 1.000f }; + break; + case HapticPreset.ImpactSoft: + timesSec = new[] { 0.16f }; + amps = new[] { 0.156f }; + break; + default: + timesSec = new[] { 0.0f }; + amps = new[] { 0.0f }; + break; + } + + var timingsMs = new long[timesSec.Length]; + var amplitudes = new int [amps.Length]; + for (int i = 0; i < timesSec.Length; i++) + { + timingsMs[i] = (long)Mathf.Round(timesSec[i] * 1000f); + amplitudes[i] = Mathf.Clamp(Mathf.RoundToInt(amps[i] * 255f), 0, 255); + } + return (timingsMs, amplitudes); + } + + private void PlayWaveform(HapticPreset preset, int repeatIndex) + { + if (!EnsureInitialized() || preset == HapticPreset.None) + { + return; + } + + var (timingsMs, amplitudes) = GetEnvelopeFor(preset); + try + { + using var effect = _vibrationEffectClass.CallStatic( + "createWaveform", timingsMs, amplitudes, repeatIndex); + _vibrator.Call("vibrate", effect); + } + catch (System.Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Haptics PlayWaveform failed: {e.Message}"); + } + } +#endif + + /// + public void PlayPresetOneShot(HapticPreset preset) + { +#if UNITY_ANDROID && !UNITY_EDITOR + PlayWaveform(preset, RepeatNone); +#endif + } + + /// + public void PlayPresetLoop(HapticPreset preset) + { +#if UNITY_ANDROID && !UNITY_EDITOR + PlayWaveform(preset, RepeatLoop); +#endif + } + + /// + public void PlayCustom(float intensity01, float durationMs) + { +#if UNITY_ANDROID && !UNITY_EDITOR + if (!EnsureInitialized() || durationMs <= 0f) + { + return; + } + + var amplitude = Mathf.Clamp(Mathf.RoundToInt(intensity01 * 255f), 1, 255); + var milliseconds = (long)Mathf.Round(durationMs); + + try + { + using var effect = _vibrationEffectClass.CallStatic( + "createOneShot", milliseconds, amplitude); + _vibrator.Call("vibrate", effect); + } + catch (System.Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Haptics PlayCustom failed: {e.Message}"); + } +#endif + } + + /// + public void Stop() + { +#if UNITY_ANDROID && !UNITY_EDITOR + if (!EnsureInitialized()) + { + return; + } + + try + { + _vibrator.Call("cancel"); + } + catch (System.Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Haptics Stop failed: {e.Message}"); + } +#endif + } + } +} diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta new file mode 100644 index 0000000..c23dcca --- /dev/null +++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c522a85822984cf6b8782ed2956ca64 \ No newline at end of file diff --git a/Runtime/Haptics/Internal/EditorHapticsBackend.cs b/Runtime/Haptics/Internal/EditorHapticsBackend.cs new file mode 100644 index 0000000..7930dc9 --- /dev/null +++ b/Runtime/Haptics/Internal/EditorHapticsBackend.cs @@ -0,0 +1,40 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Editor backend. Logs every call to the Unity console for visibility while developing; + /// no native haptic is produced. is always false so caller + /// code that gates on capability behaves the same as on a real unsupported device. + /// + internal sealed class EditorHapticsBackend : IHapticsBackend + { + /// + public bool IsSupported => false; + + /// + public void PlayPresetOneShot(HapticPreset preset) + { + Debug.Log($"[Haptics] PlayPresetOneShot({preset})"); + } + + /// + public void PlayPresetLoop(HapticPreset preset) + { + Debug.Log($"[Haptics] PlayPresetLoop({preset})"); + } + + /// + public void PlayCustom(float intensity01, float durationMs) + { + Debug.Log($"[Haptics] PlayCustom(intensity={intensity01:0.00}, durationMs={durationMs:0})"); + } + + /// + public void Stop() + { + Debug.Log("[Haptics] Stop"); + } + } +} diff --git a/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta b/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta new file mode 100644 index 0000000..945abac --- /dev/null +++ b/Runtime/Haptics/Internal/EditorHapticsBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e52f62169c0404270a043e679e2cd3be \ No newline at end of file diff --git a/Runtime/Haptics/Internal/HapticsHost.cs b/Runtime/Haptics/Internal/HapticsHost.cs new file mode 100644 index 0000000..c53ba29 --- /dev/null +++ b/Runtime/Haptics/Internal/HapticsHost.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Internal MonoBehaviour that owns the single auto-stop coroutine for time-bounded haptics. + /// Spawned lazily by the first time a Play* call is made. + /// + internal sealed class HapticsHost : MonoBehaviour + { + private Coroutine _activeCoroutine; + private Action _onStop; + + /// Schedules a one-time stop after real-time seconds. + public void ScheduleStop(float delaySeconds, Action onStop) + { + Cancel(); + _onStop = onStop; + _activeCoroutine = StartCoroutine(StopAfterDelay(delaySeconds)); + } + + /// Cancels any pending auto-stop. Does not invoke the stop callback. + public void Cancel() + { + if (_activeCoroutine != null) + { + StopCoroutine(_activeCoroutine); + _activeCoroutine = null; + } + _onStop = null; + } + + private IEnumerator StopAfterDelay(float delaySeconds) + { + yield return new WaitForSecondsRealtime(delaySeconds); + var callback = _onStop; + _onStop = null; + _activeCoroutine = null; + callback?.Invoke(); + } + + private void OnDestroy() + { + Cancel(); + } + } +} diff --git a/Runtime/Haptics/Internal/HapticsHost.cs.meta b/Runtime/Haptics/Internal/HapticsHost.cs.meta new file mode 100644 index 0000000..1b23567 --- /dev/null +++ b/Runtime/Haptics/Internal/HapticsHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a0811ba6a7ee477fbd1a0aa4984defe \ No newline at end of file diff --git a/Runtime/Haptics/Internal/IHapticsBackend.cs b/Runtime/Haptics/Internal/IHapticsBackend.cs new file mode 100644 index 0000000..d3a6c44 --- /dev/null +++ b/Runtime/Haptics/Internal/IHapticsBackend.cs @@ -0,0 +1,25 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Platform-specific implementation of haptic playback. Selected at construction time by + /// based on the current build/runtime platform. + /// + internal interface IHapticsBackend + { + /// True when the underlying device + OS can produce at least basic vibration; false otherwise. + bool IsSupported { get; } + + /// Play the preset's natural one-shot duration. + void PlayPresetOneShot(HapticPreset preset); + + /// Start looping the preset; loop continues until or auto-stop coroutine fires. + void PlayPresetLoop(HapticPreset preset); + + /// Play a single custom-intensity haptic. Intensity is in [0, 1]; duration in milliseconds. + void PlayCustom(float intensity01, float durationMs); + + /// Stop all active vibration immediately. + void Stop(); + } +} diff --git a/Runtime/Haptics/Internal/IHapticsBackend.cs.meta b/Runtime/Haptics/Internal/IHapticsBackend.cs.meta new file mode 100644 index 0000000..da18924 --- /dev/null +++ b/Runtime/Haptics/Internal/IHapticsBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca4fd752d08a14855bd0663f8fc110ea \ No newline at end of file diff --git a/Runtime/Haptics/Internal/IosHapticsBackend.cs b/Runtime/Haptics/Internal/IosHapticsBackend.cs new file mode 100644 index 0000000..fed89f2 --- /dev/null +++ b/Runtime/Haptics/Internal/IosHapticsBackend.cs @@ -0,0 +1,75 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System.Runtime.InteropServices; +using UnityEngine; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// iOS implementation built directly on UIKit feedback generators. Maps presets as follows: + /// + /// UISelectionFeedbackGenerator + /// / / + /// UINotificationFeedbackGenerator + /// / / + /// / / + /// UIImpactFeedbackGenerator with the matching style + /// + /// Looping is performed natively by re-firing the chosen generator on an NSTimer until . + /// + internal sealed class IosHapticsBackend : IHapticsBackend + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] private static extern void _GameLoversHapticsPreset(int presetId); + [DllImport("__Internal")] private static extern void _GameLoversHapticsLoopStart(int presetId); + [DllImport("__Internal")] private static extern void _GameLoversHapticsCustom(float intensity, float durationMs); + [DllImport("__Internal")] private static extern void _GameLoversHapticsStop(); +#endif + + /// + public bool IsSupported + { + get + { +#if UNITY_IOS && !UNITY_EDITOR + return SystemInfo.deviceType == DeviceType.Handheld; +#else + return false; +#endif + } + } + + /// + public void PlayPresetOneShot(HapticPreset preset) + { +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversHapticsPreset((int)preset); +#endif + } + + /// + public void PlayPresetLoop(HapticPreset preset) + { +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversHapticsLoopStart((int)preset); +#endif + } + + /// + public void PlayCustom(float intensity01, float durationMs) + { +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversHapticsCustom(intensity01, durationMs); +#endif + } + + /// + public void Stop() + { +#if UNITY_IOS && !UNITY_EDITOR + _GameLoversHapticsStop(); +#endif + } + } +} diff --git a/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta b/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta new file mode 100644 index 0000000..30a19e4 --- /dev/null +++ b/Runtime/Haptics/Internal/IosHapticsBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2ec3eb1845e014a7dbb3d4c23751deb2 \ No newline at end of file diff --git a/Runtime/Haptics/Internal/NoOpHapticsBackend.cs b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs new file mode 100644 index 0000000..d372363 --- /dev/null +++ b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs @@ -0,0 +1,25 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Fallback backend for platforms without haptic support (desktop, WebGL, etc.). + /// All members are no-ops; is always false. + /// + internal sealed class NoOpHapticsBackend : IHapticsBackend + { + /// + public bool IsSupported => false; + + /// + public void PlayPresetOneShot(HapticPreset preset) { } + + /// + public void PlayPresetLoop(HapticPreset preset) { } + + /// + public void PlayCustom(float intensity01, float durationMs) { } + + /// + public void Stop() { } + } +} diff --git a/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta new file mode 100644 index 0000000..830db52 --- /dev/null +++ b/Runtime/Haptics/Internal/NoOpHapticsBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 951dbdc8cf9e542e0bb3a9cef97904e8 \ No newline at end of file diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs index 57dc6e0..2b3e55a 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -8,8 +8,8 @@ namespace GameLovers.MobileServices.NativeUi public enum AlertButtonStyle { Default, - Positive, - Negative + Destructive, + Cancel } public struct AlertButton @@ -101,7 +101,146 @@ public static void ShowToastMessage(string message, bool isLongDuration) throw new SystemException("Show a Toast message is only available for iOS and Android platforms"); #endif } - + + /// + /// Requests an OS-mediated app rating prompt. iOS uses SKStoreReviewController; Android uses + /// the Play In-App Review API. Both platforms throttle requests internally, so calling this + /// frequently does NOT spam the user — the OS decides whether to actually show the prompt. + /// On Editor / unsupported platforms this is a safe no-op. + /// + /// + /// Android requires the Play Core Review library on the consumer's classpath. Add to + /// mainTemplate.gradle: + /// implementation 'com.google.android.play:review:2.0.1' (or newer). + /// Without that dependency this call logs an error and returns; it does not throw. + /// + public static void RequestReview() + { +#if UNITY_EDITOR + Debug.Log("Request Review is not available in the editor."); +#elif UNITY_IOS + RequestReviewNative(); +#elif UNITY_ANDROID + RequestReviewAndroid(); +#endif + } + + /// + /// Opens the OS share sheet with the given content. Any combination of , + /// , and may be supplied; nulls are skipped. + /// must be an absolute filesystem path. is + /// used as the chooser title (Android) and is ignored on iOS. + /// On Editor / unsupported platforms this is a safe no-op. + /// + public static void Share(string text, string url = null, string imagePath = null, string title = null) + { +#if UNITY_EDITOR + Debug.Log($"Share is not available in the editor (text='{text}', url='{url}', imagePath='{imagePath}')."); +#elif UNITY_IOS + ShareNative(text ?? string.Empty, url ?? string.Empty, imagePath ?? string.Empty); +#elif UNITY_ANDROID + ShareAndroid(text, url, imagePath, title); +#endif + } + +#if UNITY_ANDROID + private static void RequestReviewAndroid() + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var managerFactory = new AndroidJavaClass("com.google.android.play.core.review.ReviewManagerFactory"); + using var manager = managerFactory.CallStatic("create", activity); + using var requestTask = manager.Call("requestReviewFlow"); + requestTask.Call("addOnCompleteListener", new ReviewFlowListener(activity, manager)); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] RequestReview failed: {e.Message}. " + + "Ensure 'com.google.android.play:review' is on the gradle classpath."); + } + } + + private static void ShareAndroid(string text, string url, string imagePath, string title) + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var intentClass = new AndroidJavaClass("android.content.Intent"); + using var intent = new AndroidJavaObject("android.content.Intent"); + + intent.Call("setAction", intentClass.GetStatic("ACTION_SEND")); + + var hasImage = !string.IsNullOrEmpty(imagePath); + if (hasImage) + { + intent.Call("setType", "image/*"); + using var uriClass = new AndroidJavaClass("android.net.Uri"); + using var fileClass = new AndroidJavaObject("java.io.File", imagePath); + using var imageUri = uriClass.CallStatic("fromFile", fileClass); + intent.Call("putExtra", intentClass.GetStatic("EXTRA_STREAM"), imageUri); + intent.Call("addFlags", intentClass.GetStatic("FLAG_GRANT_READ_URI_PERMISSION")); + } + else + { + intent.Call("setType", "text/plain"); + } + + var combinedText = string.IsNullOrEmpty(url) ? text : (string.IsNullOrEmpty(text) ? url : text + " " + url); + if (!string.IsNullOrEmpty(combinedText)) + { + intent.Call("putExtra", intentClass.GetStatic("EXTRA_TEXT"), combinedText); + } + + using var chooser = intentClass.CallStatic("createChooser", intent, title ?? string.Empty); + activity.Call("startActivity", chooser); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] Share failed: {e.Message}"); + } + } + + private class ReviewFlowListener : AndroidJavaProxy + { + private readonly AndroidJavaObject _activity; + private readonly AndroidJavaObject _manager; + + // Modern Play In-App Review (v2.x of com.google.android.play:review) uses Google Play Services Tasks. + // The legacy com.google.android.play.core.tasks.OnCompleteListener applies to the deprecated + // monolithic com.google.android.play:core library only — do not use it here. + public ReviewFlowListener(AndroidJavaObject activity, AndroidJavaObject manager) + : base("com.google.android.gms.tasks.OnCompleteListener") + { + _activity = activity; + _manager = manager; + } + + // ReSharper disable once InconsistentNaming + public void onComplete(AndroidJavaObject task) + { + try + { + if (!task.Call("isSuccessful")) + { + Debug.LogWarning("[GameLovers.MobileServices] requestReviewFlow returned an unsuccessful task."); + return; + } + + using var reviewInfo = task.Call("getResult"); + using var launchTask = _manager.Call("launchReviewFlow", _activity, reviewInfo); + _ = launchTask; + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] launchReviewFlow failed: {e.Message}"); + } + } + } +#endif + #if UNITY_IOS internal delegate void AlertButtonDelegate(string buttonText); @@ -112,6 +251,12 @@ private static extern void AlertMessage(bool isSheet, string title, string messa [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void ToastMessage(string message, bool isLongDuration); + [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversRequestReview")] + private static extern void RequestReviewNative(); + + [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversShare")] + private static extern void ShareNative(string text, string url, string imagePath); + [AOT.MonoPInvokeCallback(typeof(AlertButtonDelegate))] private static void AlertButtonCallback(string buttonText) { @@ -156,9 +301,9 @@ private static int ConvertToAndroidStyle(AlertButtonStyle style) { case AlertButtonStyle.Default: return -3; - case AlertButtonStyle.Positive: + case AlertButtonStyle.Destructive: return -1; - case AlertButtonStyle.Negative: + case AlertButtonStyle.Cancel: return -2; default: throw new ArgumentOutOfRangeException(nameof(style), style, "Wrong given style"); diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..0ce3830 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f209a04a0a25423f934346a08c95472 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md new file mode 100644 index 0000000..c77ba4a --- /dev/null +++ b/Tests/AGENTS.md @@ -0,0 +1,93 @@ +# GameLovers.MobileServices Tests - AI Agent Guide + +This file contains testing conventions for the `com.gamelovers.mobileservices` package. It is the source of truth when reading, editing, or creating test files under `Tests/`. + +For runtime architecture, gotchas, and package-level context, see the parent [`AGENTS.md`](../AGENTS.md). + +## 1. Placement Rules (EditMode vs PlayMode) +- **EditMode / Unit** (`EditMode/Unit/`): Pure-logic types and services whose Editor-platform code path is a simple log / safe no-op. Includes: + - `NativeUiService` (Editor logs only) + - `HapticsService` exercised through its internal `IHapticsBackend` injection ctor (no `HapticsHost` spawn) + - `EditorHapticsBackend`, `NoOpHapticsBackend` + - `ActiveGesture`, `SwipeInput`, `TapInput` (gesture math) + - `PendingNotification`, `EditorGameNotification`, `GameNotificationChannel`, `OperatingMode` + - `ScreenWakeService`, `IosAudioSessionService`, `PermissionsService` (Editor short-circuits to `Granted`), `AttService` (Editor short-circuits to `Authorized`) + - `DeviceService` injection ctor (with NSubstitute mocks) + - `DeepLinkService` cold-start-absent path (Editor `Application.absoluteURL` is empty) + - `SafeAreaContainer` (UI Toolkit `VisualElement`, no host needed) + - Use `[Test]`. NSubstitute is referenced only on the EditMode asmdef. +- **PlayMode / Unit** (`PlayMode/Unit/`): Anything that spawns or relies on `DontDestroyOnLoad` MonoBehaviours or Unity callback frames: + - `MobileNotificationService` (creates `GameObject("NotificationService")`) + - `HapticsService` auto-stop paths (spawn `HapticsHost`) + - `DeviceServicesHost` (LateUpdate / SecondTick / Focus / iOS-LPM fan-out) + - `SafeAreaService`, `BatteryService`, `ConnectivityService` (depend on `DeviceServicesHost`) + - `PermissionsCallbackReceiver`, `AttCallbackReceiver` (need a live MonoBehaviour to receive `UnitySendMessage`-style payloads) + - Use `[UnityTest]` returning `IEnumerator`. +- **PlayMode / Smoke** (`PlayMode/Smoke/`): Lightweight "instantiate without throwing" tests. `GestureController` lives here — driving real `EnhancedTouch` events deterministically requires test-input plumbing that exceeds the smoke-test scope; we only verify lifecycle (enable/disable subscription). + +**Decision tree**: if the type spawns a `GameObject`, subscribes to a Unity callback that needs a frame to fire, or relies on an internal MonoBehaviour singleton → **PlayMode**; otherwise → **EditMode**. + +## 2. Namespace and Suppression +All test files use `namespace GameLoversEditor.MobileServices.Tests` with the suppression comment: +```csharp +// ReSharper disable once CheckNamespace +``` + +This matches the convention established by sibling package `com.gamelovers.services` (namespace `GameLoversEditor.Services.Tests`). The `GameLoversEditor.*` prefix signals "these types live in test-only assemblies" and avoids shadowing runtime namespaces. + +## 3. Naming +- **Test class**: `{TypeName}Test` for EditMode (e.g., `HapticsServiceTest`, `GameNotificationChannelTest`); add a `PlayMode` suffix when an EditMode test class for the same type already exists (e.g., `HapticsServicePlayModeTest`). Smoke tests use `{TypeName}SmokeTest`. +- **Test method**: `MethodOrBehavior_Condition_ExpectedResult` — e.g., `PlayPreset_None_NoBackendCall`, `Ctor_NullNotification_ThrowsArgumentNullException`, `KeepAwake_True_SetsScreenSleepTimeoutNeverSleep`. +- **SetUp method**: Named `Init()`. +- **TearDown method**: Named `Dispose()` (when calling `service.Dispose()`) or `Cleanup()` (when doing `Object.Destroy` / `DeviceServicesHost.ResetForTests()`). + +## 4. Mock / Helper Types +- Define mock interfaces and classes as **nested types** inside the test class when needed. +- EditMode tests use **NSubstitute** (`Substitute.For()`) for interface mocking — referenced only in the EditMode asmdef. +- For internal types whose construction shape is awkward to mock (e.g. `IHapticsBackend`), prefer hand-written `private sealed class FakeXBackend : IXBackend` nested inside the test class with explicit counters; this keeps the test reading like the production call sequence. +- PlayMode tests use concrete MonoBehaviour stubs / direct interaction with the real type. NSubstitute is **not** referenced in the PlayMode asmdef. + +## 5. Black-Box Testing Policy +- **No reflection-based testing.** Tests must exercise the runtime code through its public/internal API surface only — no `BindingFlags.NonPublic` reads or writes of private fields, properties, or events. +- Internal types and members are accessible thanks to `Runtime/AssemblyInfo.cs` granting `[assembly: InternalsVisibleTo("GameLovers.MobileServices.{Edit,Play}Mode.Tests")]`. That access is intentional and is **not** considered reflection. +- If a code path is genuinely unreachable through any black-box surface (e.g. `DeepLinkService` cold-start replay needs `Application.absoluteURL`, which the Editor cannot fabricate), the test for that path is **omitted** rather than worked around. The path is documented in this file (see §9 below) and verified manually on-device. + +## 6. Fields and Setup +- Fields are prefixed with `_` and use **concrete types** (not interfaces): `private HapticsService _haptics;`, `private DeviceServicesHost _host;`. +- Constants use `PascalCase`: `private const float DefaultLevel = 0.42f;`. +- `[SetUp]` creates fresh service instances. Services that create or hold references to GameObjects (`MobileNotificationService`, `HapticsService` after a Play* with auto-stop, anything depending on `DeviceServicesHost`) **must** call `Dispose()` and/or `DeviceServicesHost.ResetForTests()` in `[TearDown]`. + +## 7. Assertion Style +- NUnit classic model only: `Assert.AreEqual`, `Assert.AreSame`, `Assert.IsTrue`, `Assert.Throws`, `Assert.DoesNotThrow`, etc. +- No constraint-model (`Assert.That(...)`) usage. +- Async tests use `await tcs.Task` (or `await Task.WhenAny(tcs.Task, Task.Delay(timeout))` for timeout safety) inside `[UnityTest]` bodies that yield `null` between awaits — there is no `[Test, Timeout]` story for `Task`-returning APIs in this package. + +## 8. PlayMode Test Cleanup +- `DeviceServicesHost`, `HapticsHost`, `PermissionsCallbackReceiver`, `AttCallbackReceiver`, and the `GameObject("NotificationService")` are all `DontDestroyOnLoad` MonoBehaviours. PlayMode tests that touch them MUST tear down in this order: + 1. Call `service.Dispose()` (which unsubscribes from the host and removes its handlers from the event lists). + 2. For host singletons, call the static `ResetForTests()` accessor where one exists (`DeviceServicesHost.ResetForTests()`); otherwise `Object.Destroy(go)` on the GameObject. +- Without the reset, the next `[SetUp]` will receive the previous test's host instance and event subscriptions → flaky cross-test interference. + +## 9. Coverage Gaps (intentional, do NOT regress to test workarounds) +The following code paths are **not** automated-testable from the EditMode/PlayMode runners and are validated manually on a device build instead. Documented here so future audits don't try to re-cover them: +- **`NativeUiService`** native paths (iOS `[DllImport]` / Android `AndroidJavaObject`) — the Editor short-circuits log-only; manual smoke on TestFlight / internal Play track. +- **`HapticsService` iOS/Android backends** (`IosHapticsBackend`, `AndroidHapticsBackend`) — wrap `[DllImport]` and `AndroidJavaObject`; manual smoke on real devices. +- **`MobileNotificationService` non-Editor flows** (`GameNotificationsMonoBehaviour` queueing/persisting notifications via PlayerPrefs across foreground/background) — Editor `CreateNotification`/`ScheduleNotification` returns the in-memory `EditorGameNotification`; the queue/clear/reschedule semantics are exercised at the `OperatingMode` enum level only. +- **`DeepLinkService` cold-start replay** — requires `Application.absoluteURL` to be non-empty, which only happens when the OS launches the app with a deep link. Black-box test #59 (cold-start absent) is the only automated coverage; the replay path is verified manually with `xcrun simctl openurl` (iOS) / `adb shell am start -a android.intent.action.VIEW -d ` (Android). +- **`GestureController` end-to-end gesture detection** — driving `UnityEngine.InputSystem.EnhancedTouch.Touch` deterministically from a test requires the Input System's `InputTestFixture`, which adds a non-trivial setup cost. Only the lifecycle (subscribe/unsubscribe) is smoke-tested; the math is fully covered through `ActiveGesture` / `SwipeInput` / `TapInput` unit tests. +- **`BatteryService` low-power-mode change events** — driving an `OnIosLowPowerModeChanged` fan-out *through* `BatteryService` requires a `DeviceServicesHost` event invocation that's not reachable without reflection; we instead test the host's fan-out directly (`DeviceServicesHostTest.OnIosLowPowerModeChanged_PublicMethod_FanOutsToSubscribers`) and trust subscription wiring in `BatteryService` ctor. + +## 10. Test Directory Layout + +| Directory | Contents | +|-----------|----------| +| `EditMode/Unit/` | NUnit + NSubstitute; pure-logic services, math types, enum sanity, Editor-shortcircuit paths, UI Toolkit container | +| `PlayMode/Unit/` | `[UnityTest]`; `MobileNotificationService`, `DeviceServicesHost`, host-dependent services, callback receivers, `HapticsService` auto-stop | +| `PlayMode/Smoke/` | `GestureController` lifecycle smoke | + +## 11. Update Policy +Update this file when: +- Test conventions change (new asmdef references, assertion style, naming patterns, new test categories) +- New test directories or categories are added +- Mock/stub patterns change (e.g., NSubstitute added to the PlayMode asmdef) +- A coverage gap from §9 becomes testable (e.g., a future Input System `InputTestFixture` adoption could promote `GestureController` from smoke to unit) diff --git a/Tests/AGENTS.md.meta b/Tests/AGENTS.md.meta new file mode 100644 index 0000000..c30f9b8 --- /dev/null +++ b/Tests/AGENTS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8b3f1d22a7c245e6a9f4d6bce71a4c12 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode.meta b/Tests/EditMode.meta new file mode 100644 index 0000000..7d549cb --- /dev/null +++ b/Tests/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 829b0d703f3d44a5f8517828ee3e67af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef new file mode 100644 index 0000000..9bd4976 --- /dev/null +++ b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef @@ -0,0 +1,26 @@ +{ + "name": "GameLovers.MobileServices.EditMode.Tests", + "rootNamespace": "GameLoversEditor.MobileServices.Tests", + "references": [ + "GameLovers.MobileServices", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "Unity.InputSystem" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "NSubstitute.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta new file mode 100644 index 0000000..531c913 --- /dev/null +++ b/Tests/EditMode/GameLovers.MobileServices.EditMode.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ff558310a7f534c149f7afcb0b947af4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Unit.meta b/Tests/EditMode/Unit.meta new file mode 100644 index 0000000..9be9710 --- /dev/null +++ b/Tests/EditMode/Unit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9fdd6e7e7b82741299c12cd727d06fec +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EditMode/Unit/ActiveGestureTest.cs b/Tests/EditMode/Unit/ActiveGestureTest.cs new file mode 100644 index 0000000..6655145 --- /dev/null +++ b/Tests/EditMode/Unit/ActiveGestureTest.cs @@ -0,0 +1,78 @@ +using GameLovers.MobileServices.Gestures; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class ActiveGestureTest + { + [Test] + public void Ctor_InitializesFieldsToStartPositionTime() + { + var start = new Vector2(100f, 200f); + var gesture = new ActiveGesture(7, start, 1.5); + + Assert.AreEqual(7, gesture.InputId); + Assert.AreEqual(start, gesture.StartPosition); + Assert.AreEqual(start, gesture.EndPosition); + Assert.AreEqual(1.5, gesture.StartTime); + Assert.AreEqual(1.5, gesture.EndTime); + Assert.AreEqual(1, gesture.Samples); + Assert.AreEqual(0f, gesture.TravelDistance); + } + + [Test] + public void SubmitPoint_SamePosition_SkipsAccumulation() + { + var gesture = new ActiveGesture(0, Vector2.zero, 0.0); + + gesture.SubmitPoint(Vector2.zero, 0.5); + + Assert.AreEqual(1, gesture.Samples); + Assert.AreEqual(0f, gesture.TravelDistance); + Assert.AreEqual(0.5, gesture.EndTime); + } + + [Test] + public void SubmitPoint_StraightLine_TravelDistanceMatchesEuclidean() + { + var gesture = new ActiveGesture(0, Vector2.zero, 0.0); + + gesture.SubmitPoint(new Vector2(10f, 0f), 0.1); + gesture.SubmitPoint(new Vector2(20f, 0f), 0.2); + gesture.SubmitPoint(new Vector2(30f, 0f), 0.3); + + Assert.AreEqual(30f, gesture.TravelDistance, 1e-4f); + Assert.AreEqual(new Vector2(30f, 0f), gesture.EndPosition); + } + + [Test] + public void SubmitPoint_StraightLine_SwipeDirectionSamenessApproachesOne() + { + var gesture = new ActiveGesture(0, Vector2.zero, 0.0); + + for (var i = 1; i <= 10; i++) + { + gesture.SubmitPoint(new Vector2(i * 5f, 0f), i * 0.05); + } + + Assert.GreaterOrEqual(gesture.SwipeDirectionSameness, 0.99f); + } + + [Test] + public void SubmitPoint_BackAndForth_SamenessLow() + { + var gesture = new ActiveGesture(0, Vector2.zero, 0.0); + + gesture.SubmitPoint(new Vector2(10f, 0f), 0.1); + gesture.SubmitPoint(new Vector2(0f, 0f), 0.2); + gesture.SubmitPoint(new Vector2(10f, 0f), 0.3); + gesture.SubmitPoint(new Vector2(0f, 0f), 0.4); + + Assert.Less(gesture.SwipeDirectionSameness, 0.5f); + } + } +} diff --git a/Tests/EditMode/Unit/ActiveGestureTest.cs.meta b/Tests/EditMode/Unit/ActiveGestureTest.cs.meta new file mode 100644 index 0000000..795a153 --- /dev/null +++ b/Tests/EditMode/Unit/ActiveGestureTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3fddc2efef1324002ac41f039207f8ac \ No newline at end of file diff --git a/Tests/EditMode/Unit/AttServiceTest.cs b/Tests/EditMode/Unit/AttServiceTest.cs new file mode 100644 index 0000000..8f96618 --- /dev/null +++ b/Tests/EditMode/Unit/AttServiceTest.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using GameLovers.MobileServices.Device; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class AttServiceTest + { + private AttService _service; + + [SetUp] + public void Init() + { + _service = new AttService(); + } + + [Test] + public void CurrentStatus_InEditor_IsAuthorized() + { + Assert.AreEqual(AttStatus.Authorized, _service.CurrentStatus); + } + + [Test] + public void RequestAuthorizationAsync_InEditor_ReturnsAuthorized() + { + Task task = _service.RequestAuthorizationAsync(); + + Assert.IsTrue(task.IsCompleted); + Assert.AreEqual(AttStatus.Authorized, task.Result); + } + } +} diff --git a/Tests/EditMode/Unit/AttServiceTest.cs.meta b/Tests/EditMode/Unit/AttServiceTest.cs.meta new file mode 100644 index 0000000..69ce310 --- /dev/null +++ b/Tests/EditMode/Unit/AttServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 21584dddb80fb48aaa3d012d8fb32c6f \ No newline at end of file diff --git a/Tests/EditMode/Unit/DeepLinkServiceTest.cs b/Tests/EditMode/Unit/DeepLinkServiceTest.cs new file mode 100644 index 0000000..688a134 --- /dev/null +++ b/Tests/EditMode/Unit/DeepLinkServiceTest.cs @@ -0,0 +1,53 @@ +using System; +using GameLovers.MobileServices.Device; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class DeepLinkServiceTest + { + private DeepLinkService _service; + + [SetUp] + public void Init() + { + _service = new DeepLinkService(); + } + + [TearDown] + public void Dispose() + { + _service.Dispose(); + } + + [Test] + public void Ctor_NoColdStartUrl_PendingColdStartLinkIsNull() + { + // Application.absoluteURL is empty in the EditMode harness — see Tests/AGENTS.md §9. + Assert.IsNull(_service.PendingColdStartLink); + } + + [Test] + public void Subscribe_NoColdStartLink_HandlerNotInvoked() + { + Uri received = null; + Action handler = uri => received = uri; + + _service.OnLinkActivated += handler; + + Assert.IsNull(received); + + _service.OnLinkActivated -= handler; + } + + [Test] + public void Dispose_DoesNotThrow_AndPendingColdStartLinkIsNull() + { + Assert.DoesNotThrow(_service.Dispose); + Assert.IsNull(_service.PendingColdStartLink); + } + } +} diff --git a/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta b/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta new file mode 100644 index 0000000..12fb794 --- /dev/null +++ b/Tests/EditMode/Unit/DeepLinkServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e31969d3c2cb430d83b736fd2ba403f \ No newline at end of file diff --git a/Tests/EditMode/Unit/DeviceServiceTest.cs b/Tests/EditMode/Unit/DeviceServiceTest.cs new file mode 100644 index 0000000..00535f5 --- /dev/null +++ b/Tests/EditMode/Unit/DeviceServiceTest.cs @@ -0,0 +1,109 @@ +using System; +using GameLovers.MobileServices.Device; +using NSubstitute; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class DeviceServiceTest + { + // Marker interfaces co-implemented with IDisposable so NSubstitute can produce a proxy + // that satisfies both the umbrella's interface contract AND the IDisposable check inside + // DeviceService.Dispose. The DeviceService ctor accepts the parent interface; the more- + // derived disposable variant is implicitly convertible to it. + public interface ISafeAreaServiceDisposable : ISafeAreaService, IDisposable { } + public interface IBatteryServiceDisposable : IBatteryService, IDisposable { } + public interface IConnectivityServiceDisposable : IConnectivityService, IDisposable { } + public interface IDeepLinkServiceDisposable : IDeepLinkService, IDisposable { } + + private ISafeAreaServiceDisposable _safeArea; + private IScreenWakeService _screenWake; + private IBatteryServiceDisposable _battery; + private IConnectivityServiceDisposable _connectivity; + private IIosAudioSessionService _audioSession; + private IPermissionsService _permissions; + private IAttService _att; + private IDeepLinkServiceDisposable _deepLink; + + [SetUp] + public void Init() + { + _safeArea = Substitute.For(); + _screenWake = Substitute.For(); + _battery = Substitute.For(); + _connectivity = Substitute.For(); + _audioSession = Substitute.For(); + _permissions = Substitute.For(); + _att = Substitute.For(); + _deepLink = Substitute.For(); + } + + [Test] + public void InjectionCtor_StoresEachChildOnMatchingProperty() + { + var service = new DeviceService( + _safeArea, + _screenWake, + _battery, + _connectivity, + _audioSession, + _permissions, + _att, + _deepLink); + + Assert.AreSame(_safeArea, service.SafeArea); + Assert.AreSame(_screenWake, service.ScreenWake); + Assert.AreSame(_battery, service.Battery); + Assert.AreSame(_connectivity, service.Connectivity); + Assert.AreSame(_audioSession, service.AudioSession); + Assert.AreSame(_permissions, service.Permissions); + Assert.AreSame(_att, service.Att); + Assert.AreSame(_deepLink, service.DeepLink); + } + + [Test] + public void Dispose_DisposesDisposableChildren_OnlyOnce() + { + var service = new DeviceService( + _safeArea, + _screenWake, + _battery, + _connectivity, + _audioSession, + _permissions, + _att, + _deepLink); + + service.Dispose(); + + _safeArea.Received(1).Dispose(); + _battery.Received(1).Dispose(); + _connectivity.Received(1).Dispose(); + _deepLink.Received(1).Dispose(); + } + + [Test] + public void Dispose_NonDisposableChildren_NoThrow() + { + var nonDisposableSafeArea = Substitute.For(); + var nonDisposableBattery = Substitute.For(); + var nonDisposableConnectivity = Substitute.For(); + var nonDisposableDeepLink = Substitute.For(); + + var service = new DeviceService( + nonDisposableSafeArea, + _screenWake, + nonDisposableBattery, + nonDisposableConnectivity, + _audioSession, + _permissions, + _att, + nonDisposableDeepLink); + + Assert.DoesNotThrow(service.Dispose); + } + } +} diff --git a/Tests/EditMode/Unit/DeviceServiceTest.cs.meta b/Tests/EditMode/Unit/DeviceServiceTest.cs.meta new file mode 100644 index 0000000..18e443b --- /dev/null +++ b/Tests/EditMode/Unit/DeviceServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 745564a89fd5c402f89ffabd6b0b1f89 \ No newline at end of file diff --git a/Tests/EditMode/Unit/EditorGameNotificationTest.cs b/Tests/EditMode/Unit/EditorGameNotificationTest.cs new file mode 100644 index 0000000..918198a --- /dev/null +++ b/Tests/EditMode/Unit/EditorGameNotificationTest.cs @@ -0,0 +1,50 @@ +using System; +using GameLovers.MobileServices.Notifications; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class EditorGameNotificationTest + { + [Test] + public void AllProperties_RoundTripGettersAndSetters() + { + var deliveryTime = new DateTime(2030, 1, 2, 3, 4, 5); + var notification = new EditorGameNotification + { + Id = 42, + Title = "Title", + Body = "Body", + Subtitle = "Sub", + Channel = "channel", + BadgeNumber = 7, + ShouldAutoCancel = true, + DeliveryTime = deliveryTime, + SmallIcon = "small", + LargeIcon = "large", + }; + + Assert.AreEqual(42, notification.Id); + Assert.AreEqual("Title", notification.Title); + Assert.AreEqual("Body", notification.Body); + Assert.AreEqual("Sub", notification.Subtitle); + Assert.AreEqual("channel", notification.Channel); + Assert.AreEqual(7, notification.BadgeNumber); + Assert.IsTrue(notification.ShouldAutoCancel); + Assert.AreEqual(deliveryTime, notification.DeliveryTime); + Assert.AreEqual("small", notification.SmallIcon); + Assert.AreEqual("large", notification.LargeIcon); + } + + [Test] + public void Scheduled_DefaultsFalse() + { + var notification = new EditorGameNotification(); + + Assert.IsFalse(notification.Scheduled); + } + } +} diff --git a/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta b/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta new file mode 100644 index 0000000..53c0174 --- /dev/null +++ b/Tests/EditMode/Unit/EditorGameNotificationTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46a10b2ebbcf243ee98cf702acc74148 \ No newline at end of file diff --git a/Tests/EditMode/Unit/EditorHapticsBackendTest.cs b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs new file mode 100644 index 0000000..c888c41 --- /dev/null +++ b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs @@ -0,0 +1,56 @@ +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Haptics.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class EditorHapticsBackendTest + { + private EditorHapticsBackend _backend; + + [SetUp] + public void Init() + { + _backend = new EditorHapticsBackend(); + } + + [Test] + public void IsSupported_AlwaysFalse() + { + Assert.IsFalse(_backend.IsSupported); + } + + [Test] + public void PlayPresetOneShot_LogsExpected() + { + LogAssert.Expect(LogType.Log, "[Haptics] PlayPresetOneShot(Selection)"); + _backend.PlayPresetOneShot(HapticPreset.Selection); + } + + [Test] + public void PlayPresetLoop_LogsExpected() + { + LogAssert.Expect(LogType.Log, "[Haptics] PlayPresetLoop(Warning)"); + _backend.PlayPresetLoop(HapticPreset.Warning); + } + + [Test] + public void PlayCustom_LogsExpected() + { + LogAssert.Expect(LogType.Log, "[Haptics] PlayCustom(intensity=0.42, durationMs=250)"); + _backend.PlayCustom(0.42f, 250f); + } + + [Test] + public void Stop_LogsExpected() + { + LogAssert.Expect(LogType.Log, "[Haptics] Stop"); + _backend.Stop(); + } + } +} diff --git a/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta new file mode 100644 index 0000000..9f3cdbe --- /dev/null +++ b/Tests/EditMode/Unit/EditorHapticsBackendTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca5d3cefd72ac439cafa9bd1b6801dcc \ No newline at end of file diff --git a/Tests/EditMode/Unit/GameNotificationChannelTest.cs b/Tests/EditMode/Unit/GameNotificationChannelTest.cs new file mode 100644 index 0000000..0af2f3a --- /dev/null +++ b/Tests/EditMode/Unit/GameNotificationChannelTest.cs @@ -0,0 +1,88 @@ +using GameLovers.MobileServices.Notifications; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class GameNotificationChannelTest + { + [Test] + public void Ctor_ThreeArg_AppliesDefaults() + { + var channel = new GameNotificationChannel("id", "name", "description"); + + Assert.AreEqual("id", channel.Id); + Assert.AreEqual("name", channel.Name); + Assert.AreEqual("description", channel.Description); + Assert.IsTrue(channel.ShowsBadge); + Assert.IsFalse(channel.ShowLights); + Assert.IsTrue(channel.Vibrates); + Assert.IsFalse(channel.HighPriority); + Assert.AreEqual(GameNotificationChannel.NotificationStyle.Popup, channel.Style); + Assert.AreEqual(GameNotificationChannel.PrivacyMode.Public, channel.Privacy); + Assert.IsNull(channel.VibrationPattern); + } + + [Test] + public void Ctor_FullArg_StoresAllFields() + { + var pattern = new long[] { 100L, 200L, 300L }; + + var channel = new GameNotificationChannel( + "id", + "name", + "description", + GameNotificationChannel.NotificationStyle.NoSound, + showsBadge: false, + showLights: true, + vibrates: false, + highPriority: true, + privacy: GameNotificationChannel.PrivacyMode.Secret, + vibrationPattern: pattern); + + Assert.AreEqual("id", channel.Id); + Assert.AreEqual("name", channel.Name); + Assert.AreEqual("description", channel.Description); + Assert.IsFalse(channel.ShowsBadge); + Assert.IsTrue(channel.ShowLights); + Assert.IsFalse(channel.Vibrates); + Assert.IsTrue(channel.HighPriority); + Assert.AreEqual(GameNotificationChannel.NotificationStyle.NoSound, channel.Style); + Assert.AreEqual(GameNotificationChannel.PrivacyMode.Secret, channel.Privacy); + } + + [Test] + public void Ctor_FullArg_VibrationPattern_IntCastFromLongArray() + { + var pattern = new long[] { 100L, 200L, 300L }; + + var channel = new GameNotificationChannel( + "id", + "name", + "description", + GameNotificationChannel.NotificationStyle.Default, + vibrationPattern: pattern); + + Assert.IsNotNull(channel.VibrationPattern); + Assert.AreEqual(3, channel.VibrationPattern.Length); + Assert.AreEqual(100, channel.VibrationPattern[0]); + Assert.AreEqual(200, channel.VibrationPattern[1]); + Assert.AreEqual(300, channel.VibrationPattern[2]); + } + + [Test] + public void Ctor_FullArg_NullVibrationPattern_PropertyIsNull() + { + var channel = new GameNotificationChannel( + "id", + "name", + "description", + GameNotificationChannel.NotificationStyle.Default, + vibrationPattern: null); + + Assert.IsNull(channel.VibrationPattern); + } + } +} diff --git a/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta b/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta new file mode 100644 index 0000000..45ab3c7 --- /dev/null +++ b/Tests/EditMode/Unit/GameNotificationChannelTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3dc398a37bcf44fb19c50f48f97ee304 \ No newline at end of file diff --git a/Tests/EditMode/Unit/HapticPresetTest.cs b/Tests/EditMode/Unit/HapticPresetTest.cs new file mode 100644 index 0000000..ba2a5ef --- /dev/null +++ b/Tests/EditMode/Unit/HapticPresetTest.cs @@ -0,0 +1,17 @@ +using GameLovers.MobileServices.Haptics; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class HapticPresetTest + { + [Test] + public void None_IsZero() + { + Assert.AreEqual(0, (int) HapticPreset.None); + } + } +} diff --git a/Tests/EditMode/Unit/HapticPresetTest.cs.meta b/Tests/EditMode/Unit/HapticPresetTest.cs.meta new file mode 100644 index 0000000..347ec0c --- /dev/null +++ b/Tests/EditMode/Unit/HapticPresetTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cfef388b254b5474eaa5d27cdeebd273 \ No newline at end of file diff --git a/Tests/EditMode/Unit/HapticsServiceTest.cs b/Tests/EditMode/Unit/HapticsServiceTest.cs new file mode 100644 index 0000000..32e5a70 --- /dev/null +++ b/Tests/EditMode/Unit/HapticsServiceTest.cs @@ -0,0 +1,205 @@ +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Haptics.Internal; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class HapticsServiceTest + { + private FakeHapticsBackend _backend; + private HapticsService _haptics; + + [SetUp] + public void Init() + { + _backend = new FakeHapticsBackend { IsSupportedValue = true }; + _haptics = new HapticsService(_backend); + } + + [Test] + public void Enabled_DefaultIsTrue() + { + Assert.IsTrue(_haptics.Enabled); + } + + [Test] + public void Enabled_SetSameValue_DoesNothing() + { + _haptics.PlayPreset(HapticPreset.Selection); + _backend.Reset(); + + _haptics.Enabled = true; + + Assert.AreEqual(0, _backend.StopCount); + } + + [Test] + public void Enabled_SetFalseWhilePlaying_StopsBackend() + { + _haptics.PlayPreset(HapticPreset.Selection); + _backend.Reset(); + + _haptics.Enabled = false; + + Assert.IsFalse(_haptics.Enabled); + Assert.AreEqual(1, _backend.StopCount); + Assert.IsFalse(_haptics.IsPlaying); + } + + [Test] + public void IsSupported_DelegatesToBackend() + { + _backend.IsSupportedValue = false; + Assert.IsFalse(_haptics.IsSupported); + + _backend.IsSupportedValue = true; + Assert.IsTrue(_haptics.IsSupported); + } + + [Test] + public void IsPlaying_FalseInitially() + { + Assert.IsFalse(_haptics.IsPlaying); + } + + [Test] + public void PlayPreset_None_NoBackendCall() + { + _haptics.PlayPreset(HapticPreset.None); + + Assert.AreEqual(0, _backend.OneShotCount); + Assert.AreEqual(0, _backend.LoopCount); + Assert.AreEqual(0, _backend.CustomCount); + Assert.IsFalse(_haptics.IsPlaying); + } + + [Test] + public void PlayPreset_Disabled_NoBackendCall() + { + _haptics.Enabled = false; + _backend.Reset(); + + _haptics.PlayPreset(HapticPreset.Selection); + + Assert.AreEqual(0, _backend.OneShotCount); + Assert.IsFalse(_haptics.IsPlaying); + } + + [Test] + public void PlayPreset_Natural_CallsOneShotAndIsPlayingTrue() + { + _haptics.PlayPreset(HapticPreset.Selection); + + Assert.AreEqual(1, _backend.OneShotCount); + Assert.AreEqual(HapticPreset.Selection, _backend.LastPreset); + Assert.IsTrue(_haptics.IsPlaying); + } + + [Test] + public void PlayPresetDuration_Zero_CallsOneShot() + { + _haptics.PlayPresetDuration(HapticPreset.Success, 0f); + + Assert.AreEqual(1, _backend.OneShotCount); + Assert.AreEqual(0, _backend.LoopCount); + } + + [Test] + public void PlayPresetDuration_NegativeOrDefault_CallsLoopNoAutoStop() + { + _haptics.PlayPresetDuration(HapticPreset.Warning); + + Assert.AreEqual(1, _backend.LoopCount); + Assert.AreEqual(HapticPreset.Warning, _backend.LastPreset); + Assert.IsTrue(_haptics.IsPlaying); + } + + [Test] + public void PlayCustom_NonPositiveDuration_NoOp() + { + // Stays in EditMode because the no-op path short-circuits BEFORE EnsureHost() + // is called — no DontDestroyOnLoad attempt. + _haptics.PlayCustom(0.5f, 0f); + _haptics.PlayCustom(0.5f, -10f); + + Assert.AreEqual(0, _backend.CustomCount); + Assert.IsFalse(_haptics.IsPlaying); + } + + // PlayCustom_ClampsIntensity01 lives in HapticsServicePlayModeTest because PlayCustom with + // a positive durationMs always spawns the HapticsHost (DontDestroyOnLoad), which is + // illegal in EditMode. + + [Test] + public void StopCurrentHaptic_NotPlaying_DoesNotCallBackendStop() + { + _haptics.StopCurrentHaptic(); + + Assert.AreEqual(0, _backend.StopCount); + } + + [Test] + public void StopCurrentHaptic_WhilePlaying_CallsBackendStopAndClearsState() + { + _haptics.PlayPreset(HapticPreset.Error); + _backend.Reset(); + + _haptics.StopCurrentHaptic(); + + Assert.AreEqual(1, _backend.StopCount); + Assert.IsFalse(_haptics.IsPlaying); + } + + private sealed class FakeHapticsBackend : IHapticsBackend + { + public bool IsSupportedValue; + public int OneShotCount; + public int LoopCount; + public int CustomCount; + public int StopCount; + public HapticPreset LastPreset; + public float LastIntensity; + public float LastDurationMs; + + public bool IsSupported => IsSupportedValue; + + public void PlayPresetOneShot(HapticPreset preset) + { + OneShotCount++; + LastPreset = preset; + } + + public void PlayPresetLoop(HapticPreset preset) + { + LoopCount++; + LastPreset = preset; + } + + public void PlayCustom(float intensity01, float durationMs) + { + CustomCount++; + LastIntensity = intensity01; + LastDurationMs = durationMs; + } + + public void Stop() + { + StopCount++; + } + + public void Reset() + { + OneShotCount = 0; + LoopCount = 0; + CustomCount = 0; + StopCount = 0; + LastPreset = HapticPreset.None; + LastIntensity = 0f; + LastDurationMs = 0f; + } + } + } +} diff --git a/Tests/EditMode/Unit/HapticsServiceTest.cs.meta b/Tests/EditMode/Unit/HapticsServiceTest.cs.meta new file mode 100644 index 0000000..0418e88 --- /dev/null +++ b/Tests/EditMode/Unit/HapticsServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 529c90f671f454ca5ad36f69525f7030 \ No newline at end of file diff --git a/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs new file mode 100644 index 0000000..35622f6 --- /dev/null +++ b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs @@ -0,0 +1,24 @@ +using GameLovers.MobileServices.Device; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class IosAudioSessionServiceTest + { + [Test] + public void ConfigureForPlayback_InEditor_LogsAndDoesNotThrow() + { + LogAssert.Expect(LogType.Log, + "[GameLovers.MobileServices] IosAudioSessionService.ConfigureForPlayback skipped (not running on iOS device)"); + + var service = new IosAudioSessionService(); + + Assert.DoesNotThrow(service.ConfigureForPlayback); + } + } +} diff --git a/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta new file mode 100644 index 0000000..c9c7700 --- /dev/null +++ b/Tests/EditMode/Unit/IosAudioSessionServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 35f7238a9a64c4b39bbe8c7d639331a7 \ No newline at end of file diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs b/Tests/EditMode/Unit/NativeUiServiceTest.cs new file mode 100644 index 0000000..291e7f1 --- /dev/null +++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs @@ -0,0 +1,77 @@ +using System; +using GameLovers.MobileServices.NativeUi; +using NUnit.Framework; +using UnityEngine.TestTools; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class NativeUiServiceTest + { + [Test] + public void ShowAlertPopUp_InEditor_LogsAndDoesNotThrow() + { + LogAssert.Expect(LogType.Log, "Show Alert Pop Up is not available in the editor and was triggered with: T - M"); + + Assert.DoesNotThrow(() => + { + NativeUiService.ShowAlertPopUp( + false, + "T", + "M", + new AlertButton { Text = "OK", Style = AlertButtonStyle.Default }); + }); + } + + [Test] + public void ShowToastMessage_InEditor_LogsAndDoesNotThrow() + { + LogAssert.Expect(LogType.Log, "Show Toast message is not available in the editor and was triggered with: hello"); + + Assert.DoesNotThrow(() => NativeUiService.ShowToastMessage("hello", false)); + } + + [Test] + public void RequestReview_InEditor_LogsAndDoesNotThrow() + { + LogAssert.Expect(LogType.Log, "Request Review is not available in the editor."); + + Assert.DoesNotThrow(NativeUiService.RequestReview); + } + + [Test] + public void Share_InEditor_LogsAndDoesNotThrow() + { + LogAssert.Expect(LogType.Log, "Share is not available in the editor (text='hello', url='https://example.com', imagePath='')."); + + Assert.DoesNotThrow(() => NativeUiService.Share("hello", "https://example.com")); + } + + [Test] + public void Share_NullOptionalArgs_InEditor_DoesNotThrow() + { + LogAssert.Expect(LogType.Log, "Share is not available in the editor (text='hi', url='', imagePath='')."); + + Assert.DoesNotThrow(() => NativeUiService.Share("hi")); + } + + [Test] + public void AlertButton_FieldRoundTrip() + { + Action callback = () => { }; + var button = new AlertButton + { + Text = "Cancel", + Style = AlertButtonStyle.Cancel, + Callback = callback, + }; + + Assert.AreEqual("Cancel", button.Text); + Assert.AreEqual(AlertButtonStyle.Cancel, button.Style); + Assert.AreSame(callback, button.Callback); + } + } +} diff --git a/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta b/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta new file mode 100644 index 0000000..170ccb3 --- /dev/null +++ b/Tests/EditMode/Unit/NativeUiServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc2995d2cea264d09bc39eb797538320 \ No newline at end of file diff --git a/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs new file mode 100644 index 0000000..58660ba --- /dev/null +++ b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs @@ -0,0 +1,24 @@ +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Haptics.Internal; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class NoOpHapticsBackendTest + { + [Test] + public void AllMembers_DoNotThrow_AndIsSupportedFalse() + { + var backend = new NoOpHapticsBackend(); + + Assert.IsFalse(backend.IsSupported); + Assert.DoesNotThrow(() => backend.PlayPresetOneShot(HapticPreset.Success)); + Assert.DoesNotThrow(() => backend.PlayPresetLoop(HapticPreset.Selection)); + Assert.DoesNotThrow(() => backend.PlayCustom(0.5f, 100f)); + Assert.DoesNotThrow(backend.Stop); + } + } +} diff --git a/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta new file mode 100644 index 0000000..b1a2bf2 --- /dev/null +++ b/Tests/EditMode/Unit/NoOpHapticsBackendTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 709296d7446a3458f8b7244f3c714a3a \ No newline at end of file diff --git a/Tests/EditMode/Unit/OperatingModeTest.cs b/Tests/EditMode/Unit/OperatingModeTest.cs new file mode 100644 index 0000000..364ec6f --- /dev/null +++ b/Tests/EditMode/Unit/OperatingModeTest.cs @@ -0,0 +1,31 @@ +using GameLovers.MobileServices.Notifications; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class OperatingModeTest + { + [Test] + public void QueueAndClear_HasQueueAndClearOnForegroundingFlags() + { + var mode = OperatingMode.QueueAndClear; + + Assert.IsTrue((mode & OperatingMode.Queue) == OperatingMode.Queue); + Assert.IsTrue((mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding); + Assert.IsFalse((mode & OperatingMode.RescheduleAfterClearing) == OperatingMode.RescheduleAfterClearing); + } + + [Test] + public void QueueClearAndReschedule_HasAllThreeFlags() + { + var mode = OperatingMode.QueueClearAndReschedule; + + Assert.IsTrue((mode & OperatingMode.Queue) == OperatingMode.Queue); + Assert.IsTrue((mode & OperatingMode.ClearOnForegrounding) == OperatingMode.ClearOnForegrounding); + Assert.IsTrue((mode & OperatingMode.RescheduleAfterClearing) == OperatingMode.RescheduleAfterClearing); + } + } +} diff --git a/Tests/EditMode/Unit/OperatingModeTest.cs.meta b/Tests/EditMode/Unit/OperatingModeTest.cs.meta new file mode 100644 index 0000000..efc5c32 --- /dev/null +++ b/Tests/EditMode/Unit/OperatingModeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88c514f3b3fb34b6a9a50c5015071f1c \ No newline at end of file diff --git a/Tests/EditMode/Unit/PendingNotificationTest.cs b/Tests/EditMode/Unit/PendingNotificationTest.cs new file mode 100644 index 0000000..905e0eb --- /dev/null +++ b/Tests/EditMode/Unit/PendingNotificationTest.cs @@ -0,0 +1,36 @@ +using System; +using GameLovers.MobileServices.Notifications; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class PendingNotificationTest + { + [Test] + public void Ctor_NullNotification_ThrowsArgumentNullException() + { + Assert.Throws(() => new PendingNotification(null)); + } + + [Test] + public void Ctor_StoresNotificationReference() + { + var notification = new EditorGameNotification { Title = "ref" }; + + var pending = new PendingNotification(notification); + + Assert.AreSame(notification, pending.Notification); + } + + [Test] + public void Reschedule_DefaultsFalse() + { + var pending = new PendingNotification(new EditorGameNotification()); + + Assert.IsFalse(pending.Reschedule); + } + } +} diff --git a/Tests/EditMode/Unit/PendingNotificationTest.cs.meta b/Tests/EditMode/Unit/PendingNotificationTest.cs.meta new file mode 100644 index 0000000..8bda138 --- /dev/null +++ b/Tests/EditMode/Unit/PendingNotificationTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9caa3338e21ea406a80d301b01ee0a02 \ No newline at end of file diff --git a/Tests/EditMode/Unit/PermissionsServiceTest.cs b/Tests/EditMode/Unit/PermissionsServiceTest.cs new file mode 100644 index 0000000..664546b --- /dev/null +++ b/Tests/EditMode/Unit/PermissionsServiceTest.cs @@ -0,0 +1,49 @@ +using System.Threading.Tasks; +using GameLovers.MobileServices.Device; +using NUnit.Framework; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class PermissionsServiceTest + { + private PermissionsService _service; + + [SetUp] + public void Init() + { + _service = new PermissionsService(); + } + + [Test] + [TestCase(AppPermission.Camera)] + [TestCase(AppPermission.Microphone)] + [TestCase(AppPermission.LocationWhenInUse)] + [TestCase(AppPermission.LocationAlways)] + [TestCase(AppPermission.PhotoLibrary)] + [TestCase(AppPermission.PhotoLibraryAddOnly)] + [TestCase(AppPermission.Notifications)] + public void Check_InEditor_AllPermissions_ReturnsGranted(AppPermission permission) + { + Assert.AreEqual(PermissionStatus.Granted, _service.Check(permission)); + } + + [Test] + [TestCase(AppPermission.Camera)] + [TestCase(AppPermission.Microphone)] + [TestCase(AppPermission.LocationWhenInUse)] + [TestCase(AppPermission.LocationAlways)] + [TestCase(AppPermission.PhotoLibrary)] + [TestCase(AppPermission.PhotoLibraryAddOnly)] + [TestCase(AppPermission.Notifications)] + public void RequestAsync_InEditor_AllPermissions_ReturnsGranted(AppPermission permission) + { + Task task = _service.RequestAsync(permission); + + Assert.IsTrue(task.IsCompleted); + Assert.AreEqual(PermissionStatus.Granted, task.Result); + } + } +} diff --git a/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta b/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta new file mode 100644 index 0000000..af510fb --- /dev/null +++ b/Tests/EditMode/Unit/PermissionsServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c93769ea53b14803bcfe00235f93cb5 \ No newline at end of file diff --git a/Tests/EditMode/Unit/SafeAreaContainerTest.cs b/Tests/EditMode/Unit/SafeAreaContainerTest.cs new file mode 100644 index 0000000..332a9f3 --- /dev/null +++ b/Tests/EditMode/Unit/SafeAreaContainerTest.cs @@ -0,0 +1,91 @@ +using System; +using GameLovers.MobileServices.Device; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class SafeAreaContainerTest + { + [Test] + public void SetSafeAreaService_AppliesPaddingFromCurrentSafeArea() + { + var fake = new FakeSafeAreaService(new Rect(10f, 20f, Screen.width - 30f, Screen.height - 50f)); + var container = new SafeAreaContainer(); + + container.SetSafeAreaService(fake); + + AssertPaddingMatches(container, fake.SafeArea); + } + + [Test] + public void SetSafeAreaService_OnSafeAreaChanged_UpdatesPadding() + { + var fake = new FakeSafeAreaService(new Rect(0f, 0f, Screen.width, Screen.height)); + var container = new SafeAreaContainer(fake); + + var newArea = new Rect(15f, 25f, Screen.width - 40f, Screen.height - 60f); + fake.RaiseChanged(newArea); + + AssertPaddingMatches(container, newArea); + } + + [Test] + public void SetSafeAreaService_Replace_UnsubscribesPreviousService() + { + var first = new FakeSafeAreaService(new Rect(0f, 0f, Screen.width, Screen.height)); + var second = new FakeSafeAreaService(new Rect(5f, 5f, Screen.width - 10f, Screen.height - 10f)); + + var container = new SafeAreaContainer(first); + container.SetSafeAreaService(second); + + Assert.AreEqual(0, first.HandlerCount); + Assert.AreEqual(1, second.HandlerCount); + } + + private static void AssertPaddingMatches(SafeAreaContainer container, Rect safeArea) + { + var screenWidth = Screen.width; + var screenHeight = Screen.height; + if (screenWidth <= 0 || screenHeight <= 0) + { + Assert.Inconclusive("Screen dimensions are not initialised in this EditMode harness; padding assertion skipped."); + return; + } + + Assert.AreEqual(safeArea.xMin, container.style.paddingLeft.value.value, 1e-3f); + Assert.AreEqual(screenWidth - safeArea.xMax, container.style.paddingRight.value.value, 1e-3f); + Assert.AreEqual(screenHeight - safeArea.yMax, container.style.paddingTop.value.value, 1e-3f); + Assert.AreEqual(safeArea.yMin, container.style.paddingBottom.value.value, 1e-3f); + } + + private sealed class FakeSafeAreaService : ISafeAreaService + { + public int HandlerCount; + + public FakeSafeAreaService(Rect safeArea) + { + SafeArea = safeArea; + } + + public Rect SafeArea { get; private set; } + + private event Action _onChanged; + + public event Action OnSafeAreaChanged + { + add { _onChanged += value; HandlerCount++; } + remove { _onChanged -= value; HandlerCount--; } + } + + public void RaiseChanged(Rect newArea) + { + SafeArea = newArea; + _onChanged?.Invoke(newArea); + } + } + } +} diff --git a/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta b/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta new file mode 100644 index 0000000..0caca79 --- /dev/null +++ b/Tests/EditMode/Unit/SafeAreaContainerTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7aca5687b73744287bdee5442cc266f7 \ No newline at end of file diff --git a/Tests/EditMode/Unit/ScreenWakeServiceTest.cs b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs new file mode 100644 index 0000000..a38f6c7 --- /dev/null +++ b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs @@ -0,0 +1,55 @@ +using GameLovers.MobileServices.Device; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class ScreenWakeServiceTest + { + private ScreenWakeService _service; + private int _originalTimeout; + + [SetUp] + public void Init() + { + _originalTimeout = Screen.sleepTimeout; + _service = new ScreenWakeService(); + } + + [TearDown] + public void Cleanup() + { + Screen.sleepTimeout = _originalTimeout; + } + + [Test] + public void KeepAwake_True_SetsScreenSleepTimeoutNeverSleep() + { + _service.KeepAwake = true; + + Assert.AreEqual(SleepTimeout.NeverSleep, Screen.sleepTimeout); + } + + [Test] + public void KeepAwake_False_RestoresSystemSetting() + { + _service.KeepAwake = true; + _service.KeepAwake = false; + + Assert.AreEqual(SleepTimeout.SystemSetting, Screen.sleepTimeout); + } + + [Test] + public void KeepAwake_Get_ReflectsScreenSleepTimeout() + { + Screen.sleepTimeout = SleepTimeout.NeverSleep; + Assert.IsTrue(_service.KeepAwake); + + Screen.sleepTimeout = SleepTimeout.SystemSetting; + Assert.IsFalse(_service.KeepAwake); + } + } +} diff --git a/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta new file mode 100644 index 0000000..44bb77f --- /dev/null +++ b/Tests/EditMode/Unit/ScreenWakeServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7c15149e36b14a5fa31081cdf5d45e3 \ No newline at end of file diff --git a/Tests/EditMode/Unit/SwipeInputTest.cs b/Tests/EditMode/Unit/SwipeInputTest.cs new file mode 100644 index 0000000..04f98b9 --- /dev/null +++ b/Tests/EditMode/Unit/SwipeInputTest.cs @@ -0,0 +1,50 @@ +using GameLovers.MobileServices.Gestures; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class SwipeInputTest + { + [Test] + public void Ctor_FromGesture_ComputesDirectionFromStartEnd() + { + var gesture = new ActiveGesture(3, Vector2.zero, 0.0); + gesture.SubmitPoint(new Vector2(50f, 0f), 0.5); + + var swipe = new SwipeInput(gesture); + + Assert.AreEqual(3, swipe.InputId); + Assert.AreEqual(Vector2.zero, swipe.StartPosition); + Assert.AreEqual(new Vector2(50f, 0f), swipe.EndPosition); + Assert.AreEqual(Vector2.right, swipe.SwipeDirection); + Assert.AreEqual(0.5, swipe.SwipeDuration); + Assert.AreEqual(50f, swipe.TravelDistance, 1e-4f); + } + + [Test] + public void Ctor_FromGesture_ZeroDuration_VelocityRemainsZero() + { + var gesture = new ActiveGesture(0, Vector2.zero, 1.0); + // No SubmitPoint → EndTime == StartTime → SwipeDuration == 0 + var swipe = new SwipeInput(gesture); + + Assert.AreEqual(0.0, swipe.SwipeDuration); + Assert.AreEqual(0f, swipe.SwipeVelocity); + } + + [Test] + public void Ctor_FromGesture_PositiveDuration_VelocityIsTravelOverDuration() + { + var gesture = new ActiveGesture(0, Vector2.zero, 0.0); + gesture.SubmitPoint(new Vector2(100f, 0f), 0.25); + + var swipe = new SwipeInput(gesture); + + Assert.AreEqual(400f, swipe.SwipeVelocity, 1e-3f); + } + } +} diff --git a/Tests/EditMode/Unit/SwipeInputTest.cs.meta b/Tests/EditMode/Unit/SwipeInputTest.cs.meta new file mode 100644 index 0000000..dd45705 --- /dev/null +++ b/Tests/EditMode/Unit/SwipeInputTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17ac4e8ce2a874ec59236ac4fab3c1a8 \ No newline at end of file diff --git a/Tests/EditMode/Unit/TapInputTest.cs b/Tests/EditMode/Unit/TapInputTest.cs new file mode 100644 index 0000000..15f2a42 --- /dev/null +++ b/Tests/EditMode/Unit/TapInputTest.cs @@ -0,0 +1,28 @@ +using GameLovers.MobileServices.Gestures; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + [TestFixture] + public class TapInputTest + { + [Test] + public void Ctor_FromGesture_CapturesPositionsDurationDriftAndTimestamp() + { + var start = new Vector2(10f, 20f); + var gesture = new ActiveGesture(0, start, 5.0); + gesture.SubmitPoint(new Vector2(12f, 23f), 5.1); + + var tap = new TapInput(gesture); + + Assert.AreEqual(start, tap.PressPosition); + Assert.AreEqual(new Vector2(12f, 23f), tap.ReleasePosition); + Assert.AreEqual(5.1, tap.TimeStamp); + Assert.AreEqual(0.1, tap.TapDuration, 1e-9); + Assert.AreEqual(gesture.TravelDistance, tap.TapDrift, 1e-4f); + } + } +} diff --git a/Tests/EditMode/Unit/TapInputTest.cs.meta b/Tests/EditMode/Unit/TapInputTest.cs.meta new file mode 100644 index 0000000..442f08b --- /dev/null +++ b/Tests/EditMode/Unit/TapInputTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fe6d6bf49beb4d609868414ec7dc61b \ No newline at end of file diff --git a/Tests/PlayMode.meta b/Tests/PlayMode.meta new file mode 100644 index 0000000..e2a6a1a --- /dev/null +++ b/Tests/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1d7efa5dae5f34bef9bb0e5d934e0c4f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef new file mode 100644 index 0000000..0cfac2b --- /dev/null +++ b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef @@ -0,0 +1,23 @@ +{ + "name": "GameLovers.MobileServices.PlayMode.Tests", + "rootNamespace": "GameLoversEditor.MobileServices.Tests", + "references": [ + "GameLovers.MobileServices", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta new file mode 100644 index 0000000..2703c74 --- /dev/null +++ b/Tests/PlayMode/GameLovers.MobileServices.PlayMode.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e6be5882e466d4dfcb852f75aa28766d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/Smoke.meta b/Tests/PlayMode/Smoke.meta new file mode 100644 index 0000000..c974c16 --- /dev/null +++ b/Tests/PlayMode/Smoke.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c23a02df8d1214e93ab3ff8eb347222a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs new file mode 100644 index 0000000..0581ccc --- /dev/null +++ b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs @@ -0,0 +1,73 @@ +using System.Collections; +using GameLovers.MobileServices.Gestures; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.InputSystem.EnhancedTouch; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class GestureControllerSmokeTest + { + private GameObject _go; + private GestureController _controller; + + [SetUp] + public void Init() + { + _go = new GameObject("GestureController"); + _controller = _go.AddComponent(); + } + + [TearDown] + public void Cleanup() + { + if (_go != null) + { + Object.Destroy(_go); + } + + // EnhancedTouchSupport is enabled in OnEnable; ensure we leave the global state clean. + if (EnhancedTouchSupport.enabled) + { + EnhancedTouchSupport.Disable(); + } + } + + [UnityTest] + public IEnumerator OnEnable_EnablesEnhancedTouchSupport_AndOnDisableDisables() + { + yield return null; + + Assert.IsTrue(EnhancedTouchSupport.enabled, + "GestureController.OnEnable should enable EnhancedTouchSupport"); + + _controller.enabled = false; + yield return null; + + Assert.IsFalse(EnhancedTouchSupport.enabled, + "GestureController.OnDisable should disable EnhancedTouchSupport"); + } + + [UnityTest] + public IEnumerator Ctor_EmitsNoEventsBeforeFingerInteraction() + { + var pressed = 0; + var swiped = 0; + var tapped = 0; + + _controller.Pressed += _ => pressed++; + _controller.Swiped += _ => swiped++; + _controller.Tapped += _ => tapped++; + + yield return null; + yield return null; + + Assert.AreEqual(0, pressed); + Assert.AreEqual(0, swiped); + Assert.AreEqual(0, tapped); + } + } +} diff --git a/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta new file mode 100644 index 0000000..f0fa2a8 --- /dev/null +++ b/Tests/PlayMode/Smoke/GestureControllerSmokeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d7563430bbaa4f04aa6308df7ec0e2e \ No newline at end of file diff --git a/Tests/PlayMode/Unit.meta b/Tests/PlayMode/Unit.meta new file mode 100644 index 0000000..109acbd --- /dev/null +++ b/Tests/PlayMode/Unit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e7384eab7d2f541fd99d760821536478 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs new file mode 100644 index 0000000..20f1952 --- /dev/null +++ b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs @@ -0,0 +1,56 @@ +using System.Collections; +using System.Threading.Tasks; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class AttCallbackReceiverTest + { + [TearDown] + public void Cleanup() + { + var go = GameObject.Find("AttCallbackReceiver"); + if (go != null) + { + Object.Destroy(go); + } + } + + [UnityTest] + public IEnumerator OnAttResult_ValidPayload_ResolvesPendingTcs() + { + var receiver = AttCallbackReceiver.Instance; + var tcs = new TaskCompletionSource(); + var id = receiver.Register(tcs); + + receiver.OnAttResult($"{id}:{(int) AttStatus.Authorized}"); + + yield return null; + + Assert.IsTrue(tcs.Task.IsCompleted); + Assert.AreEqual(AttStatus.Authorized, tcs.Task.Result); + } + + [UnityTest] + public IEnumerator OnAttResult_MalformedPayload_DoesNotThrow() + { + var receiver = AttCallbackReceiver.Instance; + var tcs = new TaskCompletionSource(); + receiver.Register(tcs); + + Assert.DoesNotThrow(() => receiver.OnAttResult("malformed")); + Assert.DoesNotThrow(() => receiver.OnAttResult("1:not-int")); + Assert.DoesNotThrow(() => receiver.OnAttResult(":3")); + + yield return null; + + Assert.IsFalse(tcs.Task.IsCompleted); + } + } +} diff --git a/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta new file mode 100644 index 0000000..832aaca --- /dev/null +++ b/Tests/PlayMode/Unit/AttCallbackReceiverTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 00d71c524cfe6406da71f61ae9cf0e93 \ No newline at end of file diff --git a/Tests/PlayMode/Unit/BatteryServiceTest.cs b/Tests/PlayMode/Unit/BatteryServiceTest.cs new file mode 100644 index 0000000..bb4fe32 --- /dev/null +++ b/Tests/PlayMode/Unit/BatteryServiceTest.cs @@ -0,0 +1,43 @@ +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class BatteryServiceTest + { + private BatteryService _service; + + [SetUp] + public void Init() + { + _service = new BatteryService(DeviceServicesHost.Instance); + } + + [TearDown] + public void Cleanup() + { + _service.Dispose(); + DeviceServicesHost.ResetForTests(); + } + + [Test] + public void Ctor_CapturesInitialLevelStatusAndLowPowerMode() + { + Assert.AreEqual(SystemInfo.batteryLevel, _service.Level); + Assert.AreEqual(SystemInfo.batteryStatus, _service.Status); + // On Editor / unsupported platforms, low-power-mode is reported as false (see BatteryService.QueryLowPowerMode). + Assert.IsFalse(_service.IsLowPowerMode); + } + + [Test] + public void Dispose_UnregistersAllHostHandlers() + { + Assert.DoesNotThrow(_service.Dispose); + Assert.DoesNotThrow(_service.Dispose, "Dispose should be idempotent (subtracting an already-removed handler is a no-op)."); + } + } +} diff --git a/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta b/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta new file mode 100644 index 0000000..f61ecd3 --- /dev/null +++ b/Tests/PlayMode/Unit/BatteryServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e22eeff1510d449328dfa1d79f3d8c25 \ No newline at end of file diff --git a/Tests/PlayMode/Unit/ConnectivityServiceTest.cs b/Tests/PlayMode/Unit/ConnectivityServiceTest.cs new file mode 100644 index 0000000..b3f36e3 --- /dev/null +++ b/Tests/PlayMode/Unit/ConnectivityServiceTest.cs @@ -0,0 +1,40 @@ +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class ConnectivityServiceTest + { + private ConnectivityService _service; + + [SetUp] + public void Init() + { + _service = new ConnectivityService(DeviceServicesHost.Instance); + } + + [TearDown] + public void Cleanup() + { + _service.Dispose(); + DeviceServicesHost.ResetForTests(); + } + + [Test] + public void Ctor_CapturesInitialReachability() + { + Assert.AreEqual(Application.internetReachability, _service.Status); + } + + [Test] + public void Dispose_UnregistersFromHost() + { + Assert.DoesNotThrow(_service.Dispose); + Assert.DoesNotThrow(_service.Dispose); + } + } +} diff --git a/Tests/PlayMode/Unit/ConnectivityServiceTest.cs.meta b/Tests/PlayMode/Unit/ConnectivityServiceTest.cs.meta new file mode 100644 index 0000000..41faf2f --- /dev/null +++ b/Tests/PlayMode/Unit/ConnectivityServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e46624612650e4e84bcc9d64e29e4631 \ No newline at end of file diff --git a/Tests/PlayMode/Unit/DeviceServicesHostTest.cs b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs new file mode 100644 index 0000000..78205a9 --- /dev/null +++ b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs @@ -0,0 +1,111 @@ +using System.Collections; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class DeviceServicesHostTest + { + [TearDown] + public void Cleanup() + { + DeviceServicesHost.ResetForTests(); + } + + [UnityTest] + public IEnumerator Instance_LazilySpawnsGameObject_DontDestroyOnLoad() + { + Assert.IsNull(GameObject.Find("DeviceServicesHost"), "Pre-condition: no host before access"); + + var host = DeviceServicesHost.Instance; + yield return null; + + var go = GameObject.Find("DeviceServicesHost"); + Assert.IsNotNull(go); + Assert.AreEqual(go, host.gameObject); + Assert.AreEqual("DontDestroyOnLoad", go.scene.name); + } + + [UnityTest] + public IEnumerator RegisterLateUpdate_FiresEachLateUpdateFrame() + { + var host = DeviceServicesHost.Instance; + var callCount = 0; + host.RegisterLateUpdate(() => callCount++); + + yield return null; + yield return null; + + Assert.GreaterOrEqual(callCount, 2); + } + + [UnityTest] + public IEnumerator RegisterSecondTick_FiresApproximatelyOncePerSecond() + { + var host = DeviceServicesHost.Instance; + var callCount = 0; + host.RegisterSecondTick(() => callCount++); + + yield return new WaitForSecondsRealtime(2.2f); + + Assert.GreaterOrEqual(callCount, 1); + Assert.LessOrEqual(callCount, 4, "Second-tick should not fire more than ~once per second"); + } + + [UnityTest] + public IEnumerator RegisterFocusChanged_FiresOnApplicationFocus() + { + var host = DeviceServicesHost.Instance; + yield return null; + + var lastFocus = false; + var callCount = 0; + host.RegisterFocusChanged(focused => + { + lastFocus = focused; + callCount++; + }); + + // Drive Unity's MonoBehaviour message dispatcher — same path the engine uses to + // notify focus changes. This is the documented black-box entry point for testing + // Unity callbacks; no private-field reflection is used. + host.SendMessage("OnApplicationFocus", true, SendMessageOptions.RequireReceiver); + + Assert.AreEqual(1, callCount); + Assert.IsTrue(lastFocus); + } + + [Test] + public void OnIosLowPowerModeChanged_PublicMethod_FanOutsToSubscribers() + { + var host = DeviceServicesHost.Instance; + var callCount = 0; + host.RegisterIosLowPowerModeChanged(() => callCount++); + + // Public entry point — same one the iOS native bridge invokes via UnitySendMessage. + host.OnIosLowPowerModeChanged(string.Empty); + host.OnIosLowPowerModeChanged(string.Empty); + + Assert.AreEqual(2, callCount); + } + + [UnityTest] + public IEnumerator ResetForTests_DestroysSingleton() + { + _ = DeviceServicesHost.Instance; + Assert.IsNotNull(GameObject.Find("DeviceServicesHost")); + + DeviceServicesHost.ResetForTests(); + + // In PlayMode ResetForTests routes through Object.Destroy(go) which is deferred + // to end-of-frame; yield once so Find can no longer locate the destroyed GO. + yield return null; + + Assert.IsNull(GameObject.Find("DeviceServicesHost")); + } + } +} diff --git a/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta new file mode 100644 index 0000000..b2d1ace --- /dev/null +++ b/Tests/PlayMode/Unit/DeviceServicesHostTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 24951a85b4e904685af703a3819e6322 \ No newline at end of file diff --git a/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs new file mode 100644 index 0000000..78f0b0f --- /dev/null +++ b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs @@ -0,0 +1,134 @@ +using System.Collections; +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Haptics.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class HapticsServicePlayModeTest + { + private FakeHapticsBackend _backend; + private HapticsService _haptics; + + [SetUp] + public void Init() + { + _backend = new FakeHapticsBackend { IsSupportedValue = true }; + _haptics = new HapticsService(_backend); + } + + [TearDown] + public void Cleanup() + { + _haptics.StopCurrentHaptic(); + + // HapticsHost is DontDestroyOnLoad and lazy-spawned; tear it down between tests so + // each [SetUp] starts from a clean slate without cross-test pollution. + var host = GameObject.Find("HapticsHost"); + if (host != null) + { + Object.Destroy(host); + } + } + + [UnityTest] + public IEnumerator PlayPresetDuration_Positive_AutoStopsAfterDuration() + { + _haptics.PlayPresetDuration(HapticPreset.Selection, 0.1f); + Assert.IsTrue(_haptics.IsPlaying); + Assert.AreEqual(0, _backend.StopCount); + + yield return new WaitForSecondsRealtime(0.25f); + + Assert.IsFalse(_haptics.IsPlaying); + Assert.AreEqual(1, _backend.StopCount); + } + + [UnityTest] + public IEnumerator PlayCustom_AutoStopsAfterDurationMs() + { + _haptics.PlayCustom(0.7f, 100f); + Assert.IsTrue(_haptics.IsPlaying); + Assert.AreEqual(0, _backend.StopCount); + + yield return new WaitForSecondsRealtime(0.25f); + + Assert.IsFalse(_haptics.IsPlaying); + Assert.AreEqual(1, _backend.StopCount); + } + + [UnityTest] + public IEnumerator StopCurrentHaptic_CancelsPendingAutoStop() + { + _haptics.PlayPresetDuration(HapticPreset.Warning, 0.2f); + _haptics.StopCurrentHaptic(); + Assert.AreEqual(1, _backend.StopCount); + Assert.IsFalse(_haptics.IsPlaying); + + yield return new WaitForSecondsRealtime(0.3f); + + // No second Stop should have fired from a pending auto-stop coroutine. + Assert.AreEqual(1, _backend.StopCount); + } + + [Test] + public void PlayCustom_ClampsIntensity01() + { + _haptics.PlayCustom(2.5f, 100f); + Assert.AreEqual(1f, _backend.LastIntensity); + + _haptics.PlayCustom(-1f, 100f); + Assert.AreEqual(0f, _backend.LastIntensity); + + _haptics.PlayCustom(0.42f, 100f); + Assert.AreEqual(0.42f, _backend.LastIntensity, 1e-6f); + } + + [UnityTest] + public IEnumerator HapticsHost_OnDestroy_CancelsPendingAutoStop() + { + _haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, 0.5f); + + var host = GameObject.Find("HapticsHost"); + Assert.IsNotNull(host, "HapticsHost should be spawned by the time PlayPresetDuration with positive duration returns"); + + Object.Destroy(host); + yield return null; + + yield return new WaitForSecondsRealtime(0.6f); + + // Host destroyed before the scheduled stop fired: no Stop callback should have run. + // _isPlaying remains true on the service because the auto-stop coroutine never reached its callback; + // caller would normally observe this and explicitly call StopCurrentHaptic(). + Assert.AreEqual(0, _backend.StopCount); + } + + private sealed class FakeHapticsBackend : IHapticsBackend + { + public bool IsSupportedValue; + public int StopCount; + public float LastIntensity; + public float LastDurationMs; + + public bool IsSupported => IsSupportedValue; + + public void PlayPresetOneShot(HapticPreset preset) { } + public void PlayPresetLoop(HapticPreset preset) { } + + public void PlayCustom(float intensity01, float durationMs) + { + LastIntensity = intensity01; + LastDurationMs = durationMs; + } + + public void Stop() + { + StopCount++; + } + } + } +} diff --git a/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta new file mode 100644 index 0000000..f50a5ed --- /dev/null +++ b/Tests/PlayMode/Unit/HapticsServicePlayModeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a3742f45641024a648abc55678eca85c \ No newline at end of file diff --git a/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs new file mode 100644 index 0000000..e65e64a --- /dev/null +++ b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs @@ -0,0 +1,90 @@ +using System.Collections; +using GameLovers.MobileServices.Notifications; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class MobileNotificationServiceTest + { + private MobileNotificationService _service; + + [SetUp] + public void Init() + { + var defaultChannel = new GameNotificationChannel("default", "Default", "default channel"); + _service = new MobileNotificationService(defaultChannel); + } + + [TearDown] + public void Cleanup() + { + var go = GameObject.Find("NotificationService"); + if (go != null) + { + Object.Destroy(go); + } + } + + [UnityTest] + public IEnumerator Ctor_CreatesNotificationServiceGameObject_DontDestroyOnLoad() + { + yield return null; + + var go = GameObject.Find("NotificationService"); + Assert.IsNotNull(go, "MobileNotificationService should create a 'NotificationService' GameObject in its ctor"); + Assert.AreEqual("DontDestroyOnLoad", go.scene.name, + "NotificationService GameObject should be marked DontDestroyOnLoad"); + } + + [Test] + public void CreateNotification_InEditor_ReturnsEditorGameNotification() + { + var notification = _service.CreateNotification(); + + Assert.IsInstanceOf(notification); + } + + [Test] + public void ScheduleNotification_InEditor_AssignsGeneratedIdWhenNull_AndReturnsPending() + { + var notification = _service.CreateNotification(); + Assert.IsFalse(notification.Id.HasValue); + + var pending = _service.ScheduleNotification(notification); + + Assert.IsNotNull(pending); + Assert.AreSame(notification, pending.Notification); + Assert.IsTrue(notification.Id.HasValue, "Editor scheduling should assign a generated id when none is provided"); + Assert.IsTrue(notification.Id.Value >= 0, "Generated id should be non-negative (Math.Abs of GetHashCode)"); + } + + [Test] + public void ScheduleNotification_InEditor_PreservesProvidedId() + { + var notification = _service.CreateNotification(); + notification.Id = 12345; + + var pending = _service.ScheduleNotification(notification); + + Assert.AreEqual(12345, pending.Notification.Id); + } + + [Test] + public void CancelNotification_DismissNotification_DoNotThrow() + { + Assert.DoesNotThrow(() => _service.CancelNotification(1)); + Assert.DoesNotThrow(() => _service.DismissNotification(1)); + } + + [Test] + public void CancelAllScheduledNotifications_DismissAllDisplayedNotifications_DoNotThrow() + { + Assert.DoesNotThrow(_service.CancelAllScheduledNotifications); + Assert.DoesNotThrow(_service.DismissAllDisplayedNotifications); + } + } +} diff --git a/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta new file mode 100644 index 0000000..3ea3ccb --- /dev/null +++ b/Tests/PlayMode/Unit/MobileNotificationServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 284d6cb1d57bd4c928a3a494b9362592 \ No newline at end of file diff --git a/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs new file mode 100644 index 0000000..212517a --- /dev/null +++ b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs @@ -0,0 +1,67 @@ +using System.Collections; +using System.Threading.Tasks; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class PermissionsCallbackReceiverTest + { + [TearDown] + public void Cleanup() + { + var go = GameObject.Find("PermissionsCallbackReceiver"); + if (go != null) + { + Object.Destroy(go); + } + } + + [UnityTest] + public IEnumerator OnPermissionResult_ValidPayload_ResolvesPendingTcs() + { + var receiver = PermissionsCallbackReceiver.Instance; + var tcs = new TaskCompletionSource(); + var id = receiver.Register(tcs); + + receiver.OnPermissionResult($"{id}:{(int) PermissionStatus.Granted}"); + + yield return null; + + Assert.IsTrue(tcs.Task.IsCompleted); + Assert.AreEqual(PermissionStatus.Granted, tcs.Task.Result); + } + + [UnityTest] + public IEnumerator OnPermissionResult_MalformedPayload_DoesNotThrow() + { + var receiver = PermissionsCallbackReceiver.Instance; + var tcs = new TaskCompletionSource(); + var id = receiver.Register(tcs); + + Assert.DoesNotThrow(() => receiver.OnPermissionResult("not-a-valid-payload")); + Assert.DoesNotThrow(() => receiver.OnPermissionResult($"{id}:not-an-int")); + Assert.DoesNotThrow(() => receiver.OnPermissionResult(":1")); + + yield return null; + + Assert.IsFalse(tcs.Task.IsCompleted, "Malformed payloads should not resolve the registered TCS."); + } + + [UnityTest] + public IEnumerator OnPermissionResult_UnknownId_NoOp() + { + var receiver = PermissionsCallbackReceiver.Instance; + + receiver.OnPermissionResult($"99999:{(int) PermissionStatus.Denied}"); + yield return null; + + Assert.Pass(); + } + } +} diff --git a/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta new file mode 100644 index 0000000..d3b8202 --- /dev/null +++ b/Tests/PlayMode/Unit/PermissionsCallbackReceiverTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e3e3402527c084641b4d257cedf38bbf \ No newline at end of file diff --git a/Tests/PlayMode/Unit/SafeAreaServiceTest.cs b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs new file mode 100644 index 0000000..abf8bda --- /dev/null +++ b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs @@ -0,0 +1,62 @@ +using System.Collections; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Device.Internal; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +// ReSharper disable once CheckNamespace + +namespace GameLoversEditor.MobileServices.Tests +{ + public class SafeAreaServiceTest + { + private SafeAreaService _service; + + [SetUp] + public void Init() + { + _service = new SafeAreaService(DeviceServicesHost.Instance); + } + + [TearDown] + public void Cleanup() + { + _service.Dispose(); + DeviceServicesHost.ResetForTests(); + } + + [Test] + public void Ctor_CapturesInitialSafeArea() + { + Assert.AreEqual(Screen.safeArea, _service.SafeArea); + } + + [UnityTest] + public IEnumerator Tick_ScreenSafeAreaUnchanged_DoesNotFireEvent() + { + var fireCount = 0; + _service.OnSafeAreaChanged += _ => fireCount++; + + yield return null; + yield return null; + yield return null; + + Assert.AreEqual(0, fireCount); + } + + [UnityTest] + public IEnumerator Dispose_UnregistersFromHost() + { + var fireCountAfterDispose = 0; + _service.OnSafeAreaChanged += _ => fireCountAfterDispose++; + _service.Dispose(); + + yield return null; + yield return null; + + Assert.AreEqual(0, fireCountAfterDispose, + "After Dispose, the safe-area service should be detached from the host's LateUpdate fan-out."); + } + } +} diff --git a/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta new file mode 100644 index 0000000..e0c418e --- /dev/null +++ b/Tests/PlayMode/Unit/SafeAreaServiceTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfe42e63e619e49be8f2302e6f8f7e0d \ No newline at end of file From b0d7eb9488f657213ac627c5833f5e7b4c644f3f Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Mon, 11 May 2026 23:21:12 +0300 Subject: [PATCH 09/32] feat(editor): render Mobile Simulator inside the Game/Simulator view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new rendering surfaces feeding the existing MobileSimulatorState broker so the truth-mirror mocks can paint right inside Unity's Game / Simulator view next to the simulated phone screen: - `MobileSimulatorRuntimeOverlay` (editor-only `[InitializeOnLoad]` bootstrap): spawns a `[EditorOnly]` `DontDestroyOnLoad` GameObject with a programmatic `PanelSettings` (`sortingOrder = short.MaxValue`, `ConstantPixelSize`, `clearColor = false`) carrying a `UIDocument` on `EnteredPlayMode`, tears down instantly on `ExitingPlayMode`. Opt-in via the new `MobileServicesSettings.EnableRuntimeSimulatorOverlay` toggle (default OFF, surfaced under a new "Editor tooling" section in Project Settings). Root is `pickingMode = Ignore` + `Color.clear` so an empty stage is transparent to clicks; active mock scrims re-absorb input for modal behaviour. - `MobileServicesDeviceSimulatorPlugin` (`UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` subclass): embeds a slim Control Panel inside Unity's Device Simulator window. Sections per subsystem (Native UI / Notifications / Device state / Permissions / ATT / Deep Links) with a top-row "Open full Explorer →" button. Auto-syncs `MobileSimulatorState.Platform` from `Application.platform` on a 500 ms `schedule.Execute(...).Every(...)` poll (chosen over `DeviceSimulator.deviceChanged` due to inconsistently-documented delegate signature across Unity 6 minor versions). Flips a new `MobileSimulatorState.IsActivePluginConnected` flag while alive; the Explorer's `Render as: iOS | Android` dropdown subscribes to `PluginConnectedChanged` and greys out (+ tooltips) while the plugin owns the platform skin — handoff is bidirectional and symmetric. Editor-tooling tests retired: deleted the `GameLovers.MobileServices.Editor.Tests` asmdef and its 5 test classes (`EditorPlatformSimulatorTest`, `MobileServicesBuildPostprocessorTest`, `MobileServicesExplorerWindowTest`, `MobileServicesSettingsTest`, `MobileSimulatorWindowTest`) plus the `Editor/AssemblyInfo.cs` bridge and the matching `InternalsVisibleTo("GameLovers.MobileServices.Editor.Tests")` grant on `Runtime/AssemblyInfo.cs`. Editor tooling is now validated manually only — codified in `Tests/AGENTS.md` §1 (new "Editor tooling (NOT tested)" group), §9 (new coverage-gap entry), §11 (new update trigger), and parent `AGENTS.md` §3 (Tests bullet rewritten). Aligns the package with what `Tests/AGENTS.md` §10's layout table already implied — `EditMode/Editor/` was never documented. Docs: `docs/explorer.md` restructured from "Mobile Simulator Window (truth-mirror)" into a "Three rendering surfaces" table + per-surface subsections; stale device-frame line dropped; recommended workflow rewritten around the Device Simulator + plugin + opt-in overlay; "When to use which" table extended with a plugin row. `AGENTS.md`: §2 Editor block describes all three rendering surfaces + `EnableRuntimeSimulatorOverlay`; §3 Layout convention adds `Editor/Explorer/DeviceSimulatorPanel/`; §3 `InternalsVisibleTo` paragraph trimmed; §4 new gotcha entry for the runtime overlay's Play-mode-only, Editor-asmdef-owned lifecycle; §8 update policy expanded. `CHANGELOG.md`: folded into the existing `## [1.0.0]` section (pre-publication versioning rule — no `package.json` version bump) — 2 new `### Added` bullets (Runtime Simulator Overlay, Device Simulator Plugin) + new `### Removed` subsection. Tested: EditMode 790 passed / 0 failed / 1 inconclusive (the documented `IsOutdatedVersion_DirectInvocation` host-version-parser brittleness); PlayMode 263 passed / 0 failed. Clean. Bundled alongside the in-flight v1.0.0 polish work (Phase A-E DX overhaul, unreleased) that landed in the working tree in a prior session. Co-authored-by: Cursor --- AGENTS.md | 54 +- CHANGELOG.md | 30 +- Editor.meta | 8 + Editor/Build.meta | 8 + .../Build/MobileServicesBuildPostprocessor.cs | 257 ++++++++++ .../MobileServicesBuildPostprocessor.cs.meta | 2 + Editor/Explorer.meta | 8 + Editor/Explorer/DeviceSimulatorPanel.meta | 8 + .../MobileServicesDeviceSimulatorPanel.uss | 50 ++ ...obileServicesDeviceSimulatorPanel.uss.meta | 12 + .../MobileServicesDeviceSimulatorPlugin.cs | 317 ++++++++++++ ...obileServicesDeviceSimulatorPlugin.cs.meta | 2 + Editor/Explorer/Overlays.meta | 8 + .../Overlays/MobileSimulator.Android.uss | 94 ++++ .../Overlays/MobileSimulator.Android.uss.meta | 12 + .../Overlays/MobileSimulator.Common.uss | 225 +++++++++ .../Overlays/MobileSimulator.Common.uss.meta | 12 + .../Explorer/Overlays/MobileSimulator.iOS.uss | 97 ++++ .../Overlays/MobileSimulator.iOS.uss.meta | 12 + .../Overlays/MobileSimulatorRuntimeOverlay.cs | 303 +++++++++++ .../MobileSimulatorRuntimeOverlay.cs.meta | 2 + .../Explorer/Overlays/MobileSimulatorState.cs | 181 +++++++ .../Overlays/MobileSimulatorState.cs.meta | 2 + .../Overlays/MobileSimulatorWindow.cs | 219 ++++++++ .../Overlays/MobileSimulatorWindow.cs.meta | 2 + Editor/Explorer/Overlays/MockBuilders.cs | 320 ++++++++++++ Editor/Explorer/Overlays/MockBuilders.cs.meta | 2 + Editor/Explorer/Tabs.meta | 8 + Editor/Explorer/Tabs/AttDeepLinkTab.cs | 154 ++++++ Editor/Explorer/Tabs/AttDeepLinkTab.cs.meta | 2 + Editor/Explorer/Tabs/DeviceTab.cs | 144 ++++++ Editor/Explorer/Tabs/DeviceTab.cs.meta | 2 + Editor/Explorer/Tabs/GesturesTab.cs | 113 +++++ Editor/Explorer/Tabs/GesturesTab.cs.meta | 2 + Editor/Explorer/Tabs/HapticsTab.cs | 212 ++++++++ Editor/Explorer/Tabs/HapticsTab.cs.meta | 2 + Editor/Explorer/Tabs/MobileServiceTab.cs | 274 ++++++++++ Editor/Explorer/Tabs/MobileServiceTab.cs.meta | 2 + Editor/Explorer/Tabs/NativeUiTab.cs | 110 ++++ Editor/Explorer/Tabs/NativeUiTab.cs.meta | 2 + Editor/Explorer/Tabs/NotificationsTab.cs | 156 ++++++ Editor/Explorer/Tabs/NotificationsTab.cs.meta | 2 + Editor/Explorer/Tabs/OverviewTab.cs | 116 +++++ Editor/Explorer/Tabs/OverviewTab.cs.meta | 2 + Editor/Explorer/Tabs/PermissionsTab.cs | 140 ++++++ Editor/Explorer/Tabs/PermissionsTab.cs.meta | 2 + Editor/Explorer/Windows.meta | 8 + .../Windows/MobileServicesExplorerWindow.cs | 194 +++++++ .../MobileServicesExplorerWindow.cs.meta | 2 + .../Windows/MobileServicesExplorerWindow.uss | 288 +++++++++++ .../MobileServicesExplorerWindow.uss.meta | 12 + .../GameLovers.MobileServices.Editor.asmdef | 22 + ...meLovers.MobileServices.Editor.asmdef.meta | 7 + Editor/Settings.meta | 8 + Editor/Settings/MobileServicesScanner.cs | 149 ++++++ Editor/Settings/MobileServicesScanner.cs.meta | 2 + Editor/Settings/MobileServicesSettings.cs | 276 ++++++++++ .../Settings/MobileServicesSettings.cs.meta | 2 + .../MobileServicesSettingsProvider.cs | 474 ++++++++++++++++++ .../MobileServicesSettingsProvider.cs.meta | 2 + Editor/Simulation.meta | 8 + Editor/Simulation/EditorPlatformSimulator.cs | 213 ++++++++ .../EditorPlatformSimulator.cs.meta | 2 + README.md | 231 +++------ Runtime/AssemblyInfo.cs | 1 + Runtime/Device/DeepLinks/DeepLinkRouter.cs | 132 +++++ .../Device/DeepLinks/DeepLinkRouter.cs.meta | 2 + Runtime/Device/DeepLinks/DeepLinkService.cs | 17 + Runtime/Device/DeepLinks/IDeepLinkRouter.cs | 41 ++ .../Device/DeepLinks/IDeepLinkRouter.cs.meta | 2 + .../Device/Permissions/IPermissionsService.cs | 21 + .../Device/Permissions/PermissionsService.cs | 31 +- Runtime/Device/State/BatteryService.cs | 20 + Runtime/Device/State/ConnectivityService.cs | 26 +- Runtime/Device/State/SafeAreaService.cs | 22 + Runtime/Device/Tracking/AttService.cs | 11 + Runtime/Haptics/HapticsService.cs | 32 ++ .../Haptics/Internal/AndroidHapticsBackend.cs | 63 +-- Runtime/Haptics/Internal/HapticEnvelopes.cs | 71 +++ .../Haptics/Internal/HapticEnvelopes.cs.meta | 2 + Runtime/IMobileService.cs | 81 +++ Runtime/IMobileService.cs.meta | 2 + Runtime/NativeUi/INativeUiService.cs | 46 ++ Runtime/NativeUi/INativeUiService.cs.meta | 2 + .../MobileNotificationService.cs | 12 +- Runtime/Notifications/NotificationBuilder.cs | 84 ++++ .../Notifications/NotificationBuilder.cs.meta | 2 + Samples~/DeepLinkRouter/DeepLinkRouterUI.cs | 168 +++++++ Samples~/DeepLinkRouter/README.md | 49 ++ Samples~/HapticsPalette/HapticsPaletteUI.cs | 204 ++++++++ Samples~/HapticsPalette/README.md | 25 + .../MobileServicesPlaygroundUI.cs | 300 +++++++++++ Samples~/MobileServicesPlayground/README.md | 32 ++ .../NotificationsSchedulerUI.cs | 198 ++++++++ Samples~/NotificationsScheduler/README.md | 24 + Samples~/README.md | 35 ++ Tests/AGENTS.md | 5 +- Tests/EditMode/Unit/DeepLinkRouterTest.cs | 116 +++++ .../EditMode/Unit/DeepLinkRouterTest.cs.meta | 2 + Tests/EditMode/Unit/HapticEnvelopesTest.cs | 92 ++++ .../EditMode/Unit/HapticEnvelopesTest.cs.meta | 2 + Tests/EditMode/Unit/MobileServiceTest.cs | 41 ++ Tests/EditMode/Unit/MobileServiceTest.cs.meta | 2 + .../Unit/MultiPermissionRequestTest.cs | 48 ++ .../Unit/MultiPermissionRequestTest.cs.meta | 2 + .../Unit/NativeUiServiceInstanceTest.cs | 49 ++ .../Unit/NativeUiServiceInstanceTest.cs.meta | 2 + .../EditMode/Unit/NotificationBuilderTest.cs | 98 ++++ .../Unit/NotificationBuilderTest.cs.meta | 2 + docs.meta | 8 + docs/README.md | 27 + docs/README.md.meta | 7 + docs/build-pipeline.md | 113 +++++ docs/build-pipeline.md.meta | 7 + docs/device.md | 128 +++++ docs/device.md.meta | 7 + docs/explorer.md | 163 ++++++ docs/explorer.md.meta | 7 + docs/gestures.md | 73 +++ docs/gestures.md.meta | 7 + docs/haptics.md | 85 ++++ docs/haptics.md.meta | 7 + docs/native-ui.md | 66 +++ docs/native-ui.md.meta | 7 + docs/notifications.md | 90 ++++ docs/notifications.md.meta | 7 + docs/samples.md | 28 ++ docs/samples.md.meta | 7 + docs/troubleshooting.md | 68 +++ docs/troubleshooting.md.meta | 7 + package.json | 24 +- 131 files changed, 8517 insertions(+), 237 deletions(-) create mode 100644 Editor.meta create mode 100644 Editor/Build.meta create mode 100644 Editor/Build/MobileServicesBuildPostprocessor.cs create mode 100644 Editor/Build/MobileServicesBuildPostprocessor.cs.meta create mode 100644 Editor/Explorer.meta create mode 100644 Editor/Explorer/DeviceSimulatorPanel.meta create mode 100644 Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss create mode 100644 Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPanel.uss.meta create mode 100644 Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs create mode 100644 Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs.meta create mode 100644 Editor/Explorer/Overlays.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulator.Android.uss create mode 100644 Editor/Explorer/Overlays/MobileSimulator.Android.uss.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulator.Common.uss create mode 100644 Editor/Explorer/Overlays/MobileSimulator.Common.uss.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulator.iOS.uss create mode 100644 Editor/Explorer/Overlays/MobileSimulator.iOS.uss.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs create mode 100644 Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulatorState.cs create mode 100644 Editor/Explorer/Overlays/MobileSimulatorState.cs.meta create mode 100644 Editor/Explorer/Overlays/MobileSimulatorWindow.cs create mode 100644 Editor/Explorer/Overlays/MobileSimulatorWindow.cs.meta create mode 100644 Editor/Explorer/Overlays/MockBuilders.cs create mode 100644 Editor/Explorer/Overlays/MockBuilders.cs.meta create mode 100644 Editor/Explorer/Tabs.meta create mode 100644 Editor/Explorer/Tabs/AttDeepLinkTab.cs create mode 100644 Editor/Explorer/Tabs/AttDeepLinkTab.cs.meta create mode 100644 Editor/Explorer/Tabs/DeviceTab.cs create mode 100644 Editor/Explorer/Tabs/DeviceTab.cs.meta create mode 100644 Editor/Explorer/Tabs/GesturesTab.cs create mode 100644 Editor/Explorer/Tabs/GesturesTab.cs.meta create mode 100644 Editor/Explorer/Tabs/HapticsTab.cs create mode 100644 Editor/Explorer/Tabs/HapticsTab.cs.meta create mode 100644 Editor/Explorer/Tabs/MobileServiceTab.cs create mode 100644 Editor/Explorer/Tabs/MobileServiceTab.cs.meta create mode 100644 Editor/Explorer/Tabs/NativeUiTab.cs create mode 100644 Editor/Explorer/Tabs/NativeUiTab.cs.meta create mode 100644 Editor/Explorer/Tabs/NotificationsTab.cs create mode 100644 Editor/Explorer/Tabs/NotificationsTab.cs.meta create mode 100644 Editor/Explorer/Tabs/OverviewTab.cs create mode 100644 Editor/Explorer/Tabs/OverviewTab.cs.meta create mode 100644 Editor/Explorer/Tabs/PermissionsTab.cs create mode 100644 Editor/Explorer/Tabs/PermissionsTab.cs.meta create mode 100644 Editor/Explorer/Windows.meta create mode 100644 Editor/Explorer/Windows/MobileServicesExplorerWindow.cs create mode 100644 Editor/Explorer/Windows/MobileServicesExplorerWindow.cs.meta create mode 100644 Editor/Explorer/Windows/MobileServicesExplorerWindow.uss create mode 100644 Editor/Explorer/Windows/MobileServicesExplorerWindow.uss.meta create mode 100644 Editor/GameLovers.MobileServices.Editor.asmdef create mode 100644 Editor/GameLovers.MobileServices.Editor.asmdef.meta create mode 100644 Editor/Settings.meta create mode 100644 Editor/Settings/MobileServicesScanner.cs create mode 100644 Editor/Settings/MobileServicesScanner.cs.meta create mode 100644 Editor/Settings/MobileServicesSettings.cs create mode 100644 Editor/Settings/MobileServicesSettings.cs.meta create mode 100644 Editor/Settings/MobileServicesSettingsProvider.cs create mode 100644 Editor/Settings/MobileServicesSettingsProvider.cs.meta create mode 100644 Editor/Simulation.meta create mode 100644 Editor/Simulation/EditorPlatformSimulator.cs create mode 100644 Editor/Simulation/EditorPlatformSimulator.cs.meta create mode 100644 Runtime/Device/DeepLinks/DeepLinkRouter.cs create mode 100644 Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta create mode 100644 Runtime/Device/DeepLinks/IDeepLinkRouter.cs create mode 100644 Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta create mode 100644 Runtime/Haptics/Internal/HapticEnvelopes.cs create mode 100644 Runtime/Haptics/Internal/HapticEnvelopes.cs.meta create mode 100644 Runtime/IMobileService.cs create mode 100644 Runtime/IMobileService.cs.meta create mode 100644 Runtime/NativeUi/INativeUiService.cs create mode 100644 Runtime/NativeUi/INativeUiService.cs.meta create mode 100644 Runtime/Notifications/NotificationBuilder.cs create mode 100644 Runtime/Notifications/NotificationBuilder.cs.meta create mode 100644 Samples~/DeepLinkRouter/DeepLinkRouterUI.cs create mode 100644 Samples~/DeepLinkRouter/README.md create mode 100644 Samples~/HapticsPalette/HapticsPaletteUI.cs create mode 100644 Samples~/HapticsPalette/README.md create mode 100644 Samples~/MobileServicesPlayground/MobileServicesPlaygroundUI.cs create mode 100644 Samples~/MobileServicesPlayground/README.md create mode 100644 Samples~/NotificationsScheduler/NotificationsSchedulerUI.cs create mode 100644 Samples~/NotificationsScheduler/README.md create mode 100644 Samples~/README.md create mode 100644 Tests/EditMode/Unit/DeepLinkRouterTest.cs create mode 100644 Tests/EditMode/Unit/DeepLinkRouterTest.cs.meta create mode 100644 Tests/EditMode/Unit/HapticEnvelopesTest.cs create mode 100644 Tests/EditMode/Unit/HapticEnvelopesTest.cs.meta create mode 100644 Tests/EditMode/Unit/MobileServiceTest.cs create mode 100644 Tests/EditMode/Unit/MobileServiceTest.cs.meta create mode 100644 Tests/EditMode/Unit/MultiPermissionRequestTest.cs create mode 100644 Tests/EditMode/Unit/MultiPermissionRequestTest.cs.meta create mode 100644 Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs create mode 100644 Tests/EditMode/Unit/NativeUiServiceInstanceTest.cs.meta create mode 100644 Tests/EditMode/Unit/NotificationBuilderTest.cs create mode 100644 Tests/EditMode/Unit/NotificationBuilderTest.cs.meta create mode 100644 docs.meta create mode 100644 docs/README.md create mode 100644 docs/README.md.meta create mode 100644 docs/build-pipeline.md create mode 100644 docs/build-pipeline.md.meta create mode 100644 docs/device.md create mode 100644 docs/device.md.meta create mode 100644 docs/explorer.md create mode 100644 docs/explorer.md.meta create mode 100644 docs/gestures.md create mode 100644 docs/gestures.md.meta create mode 100644 docs/haptics.md create mode 100644 docs/haptics.md.meta create mode 100644 docs/native-ui.md create mode 100644 docs/native-ui.md.meta create mode 100644 docs/notifications.md create mode 100644 docs/notifications.md.meta create mode 100644 docs/samples.md create mode 100644 docs/samples.md.meta create mode 100644 docs/troubleshooting.md create mode 100644 docs/troubleshooting.md.meta diff --git a/AGENTS.md b/AGENTS.md index a0114f3..340ce2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,13 +10,14 @@ - `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`). -- **Notifications**: platform wrapper over Unity Mobile Notifications (Android/iOS). +- **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 8 sub-services — `SafeArea`, `ScreenWake`, `Battery` (with iOS / Android low-power-mode awareness), `Connectivity`, `AudioSession` (iOS silent-switch override), `Permissions` (unified iOS+Android, Task-based async), `Att` (App Tracking Transparency, no `com.unity.ads.ios-support` dep), `DeepLink` (with cold-start link queueing). +- **Device**: `IDeviceService` umbrella facade over 8 sub-services — `SafeArea`, `ScreenWake`, `Battery` (with iOS / Android low-power-mode awareness), `Connectivity`, `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. This file is for contributors/agents working on the package itself. +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) @@ -61,6 +62,7 @@ This namespace holds the umbrella facade plus every device-touching service. All - **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`) @@ -86,16 +88,44 @@ This namespace holds the umbrella facade plus every device-touching service. All - **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. +- **`MobileServicesExplorerWindow`**: `Editor/Explorer/Windows/MobileServicesExplorerWindow.cs` + `.uss`. Menu item `Tools/GameLovers/Mobile Services Explorer`. `TabView` with 8 tabs and a top-row platform-skin dropdown (`iOS | Android`) that pushes `MobileSimulatorState.Platform`. +- **`MobileServiceTab` base** (`Editor/Explorer/Tabs/MobileServiceTab.cs`): abstract base mirroring the `ServiceTab` pattern in `com.gamelovers.services`. Implements the workspace UIToolkit gotcha-hardening pieces: play-mode-aware refresh scheduling, `tab-banner` empty-state, `MakePrimaryDangerButton`, `MakeStickyFoldout`, `TryShortCircuitRefresh`/`InvalidateRefreshDigest`. Tabs that nuke-and-rebuild their hierarchy MUST use `MakeStickyFoldout` + `TryShortCircuitRefresh` per the same workspace rules that apply to the services package. +- **Tabs** (`Editor/Explorer/Tabs/*.cs`): `OverviewTab`, `NativeUiTab`, `HapticsTab`, `NotificationsTab`, `GesturesTab`, `DeviceTab`, `PermissionsTab`, `AttDeepLinkTab`. Each is a `sealed` subclass of `MobileServiceTab`. +- **Truth-mirror simulator — three rendering surfaces** (`Editor/Explorer/Overlays/` + `Editor/Explorer/DeviceSimulatorPanel/`): all three subscribe to the same `MobileSimulatorState` (singleton broker / event bus) and reuse `MockBuilders` (per-shape factory methods) + the three USS files (`MobileSimulator.Common.uss`, `MobileSimulator.iOS.uss`, `MobileSimulator.Android.uss`) swapped at runtime when the platform flips. A persistent `[EDITOR SIMULATOR]` watermark is non-removable on every surface. + - **`MobileSimulatorWindow`** — dockable `EditorWindow`; menu item `Tools/GameLovers/Mobile Services Simulator Window`. Alive in edit + play mode. The original "open it next to the Game view" target. + - **`MobileSimulatorRuntimeOverlay`** — editor-only `[InitializeOnLoad]` bootstrap that spawns a `[EditorOnly] MobileSimulatorOverlay` `DontDestroyOnLoad` GameObject with a UIDocument + programmatic `PanelSettings` (`sortingOrder = short.MaxValue`) on `EnteredPlayMode`. Tears down on `ExitingPlayMode` (clean teardown — no paused-snapshot mode). Opt-in via `MobileServicesSettings.EnableRuntimeSimulatorOverlay`. Renders pixel-aligned with the simulated device's `Screen.*` values so mocks land where Apple's reviewer would see them. + - **`MobileServicesDeviceSimulatorPlugin`** (`Editor/Explorer/DeviceSimulatorPanel/`) — `UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` subclass auto-discovered by Unity, embeds a slim Control Panel inside Unity's Device Simulator window. Auto-syncs `MobileSimulatorState.Platform` from the selected device profile by reading `Application.platform` on a 500 ms `schedule.Execute` poll (robust across Unity 6 minor versions where `DeviceSimulator.deviceChanged` signature varies). Sets `MobileSimulatorState.IsActivePluginConnected = true` while alive — Explorer header consumes this flag to grey out its own `Render as: iOS | Android` dropdown. +- **`EditorPlatformSimulator`** (`Editor/Simulation/EditorPlatformSimulator.cs`, namespace `GameLovers.MobileServices.Editor.Simulation`): static editor-only façade exposing `SetIosLowPowerMode`, `SetSafeArea` / `ClearSafeAreaOverride`, `SetConnectivity`, `SimulateDeepLink`, `QueuePermissionResult` / `SetPermissionCheckResult`, `QueueAttResult`, `DismissAllOverlays`. Drives runtime services via the `internal` editor hooks documented under §2 below. +- **Editor-only runtime hooks** (consumed only when `UNITY_EDITOR`): `BatteryService.EditorLowPowerModeOverride` + `SimulateLowPowerModeChanged()`, `ConnectivityService.EditorReachabilityOverride` + `SimulateStatusChanged()`, `SafeAreaService.EditorSafeAreaOverride` + `SimulateSafeAreaChanged()`, `DeepLinkService.SimulateLinkActivated(Uri)`, `PermissionsService.EditorCheckOverride` / `EditorRequestOverride`, `AttService.EditorCurrentStatusOverride` / `EditorRequestResultOverride`. 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 Explorer 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 Mobile Services Explorer envelope graph both read from it — single source of truth. +- **`MobileServicesSettings`** (`Editor/Settings/MobileServicesSettings.cs`): `ScriptableSingleton` persisted to `ProjectSettings/MobileServicesSettings.asset`. Holds per-permission iOS usage descriptions (per-locale `LocaleEntry` rows; English mandatory), ATT usage description, capability toggles, Android manifest opt-ins, the CI-mode `AllowPlaceholderUsageDescriptions` toggle, and the `EnableRuntimeSimulatorOverlay` opt-in for the in-Game-view simulator overlay. Surfaced via `MobileServicesSettingsProvider` at `Edit > Project Settings > GameLovers > Mobile Services` (UIToolkit) with live missing-keys badge, project-scan button (uses `MobileServicesScanner`), privacy-nutrition-label draft generator, and an `Editor tooling` section housing the runtime-overlay toggle. Critical: needs `using UnityEngine;` per workspace `ScriptableSingleton and [SerializeField]` rule. +- **`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`. iOS path mutates the post-build Xcode project via `PlistDocument` + `ProjectCapabilityManager` (entitlements file `GameLoversMobileServices.entitlements`). Android path patches `Assets/Plugins/Android/mainTemplate.xml` with the configured `` entries + share-chooser `` block. Idempotent on re-runs. Fail-by-default validation throws `BuildFailedException` listing every missing usage description; soft mode injects placeholder strings instead. + +### 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")`. +- **`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 Explorer's introspection wedge. No `Editor.Tests` grant — editor tooling is not automated-tested (see `Tests/AGENTS.md`). +- **Editor folder mirrors the services package layout** — `Editor/Explorer/{Tabs,Windows,Overlays,DeviceSimulatorPanel}/` + `Editor/Simulation/`. Editor asmdef name is `GameLovers.MobileServices.Editor`. Tabs use the `GameLovers.MobileServices.Editor.Explorer.Tabs` namespace, the window uses `GameLovers.MobileServices.Editor.Explorer.Windows`, the simulator overlay machinery (truth-mirror window + runtime overlay bootstrap) uses `GameLovers.MobileServices.Editor.Explorer.Overlays`, the `DeviceSimulatorPlugin` lives in `GameLovers.MobileServices.Editor.Explorer.DeviceSimulatorPanel`, the simulator façade uses `GameLovers.MobileServices.Editor.Simulation`. 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. Tests do NOT mirror the runtime folder structure — group by feature, not by source path. +- **Tests** live under `Tests/{EditMode,PlayMode}/` with one asmdef each. **Editor tooling is not automated-tested** — types under `Editor/` (Explorer windows / tabs, `MobileSimulatorWindow`, `MobileSimulatorRuntimeOverlay`, `MobileServicesDeviceSimulatorPlugin`, `EditorPlatformSimulator`, `MobileServicesSettings*`, `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** @@ -131,6 +161,10 @@ Section §2 names every public type and the assembly it lives in. Use that plus - 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. +- **Runtime simulator overlay is Play-mode-only and Editor-asmdef-owned** + - `MobileSimulatorRuntimeOverlay` lives in the Editor asmdef and spawns a `[EditorOnly]` `DontDestroyOnLoad` GameObject only when `MobileServicesSettings.EnableRuntimeSimulatorOverlay` is `true` and Unity enters play mode. The GameObject is destroyed instantly on `ExitingPlayMode`. + - 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. @@ -163,9 +197,13 @@ When you need third-party source/docs, prefer the locally-cached UPM packages: ## 8. Update Policy Update this file when: -- Public API changes (`NativeUiService`, `INotificationService`, `IGameNotification`, `GestureController` events, `IHapticsService`, `IDeviceService` and any of its 8 child interfaces) +- Public API changes (`NativeUiService`, `INativeUiService`, `INotificationService` + `NotificationBuilder`, `IGameNotification`, `GestureController` events, `IHapticsService`, `IDeviceService` and any of its 8 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 `AndroidHapticsBackend` and per-preset routing in `Plugins/iOS/Haptics.m`) +- 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 (Explorer tabs, `EditorPlatformSimulator` API, internal introspection accessors, simulator USS / overlay payloads, menu items under `Tools/GameLovers/Mobile Services*`, `MobileSimulatorRuntimeOverlay` lifecycle, `MobileServicesDeviceSimulatorPlugin` panel layout / auto-platform-sync behaviour) +- `MobileServicesSettings` schema (new `[SerializeField]` rows on the asset — current set: usage descriptions, ATT usage, capability toggles, Android manifest toggles, `AllowPlaceholderUsageDescriptions`, `ScanPopulatedCapabilities`, `EnableRuntimeSimulatorOverlay`), settings panel layout, project scanner detection rules, build postprocessor mutation logic (Info.plist keys, entitlements capabilities, Android manifest entries, queries block) +- `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/CHANGELOG.md b/CHANGELOG.md index e2f9be7..f5713be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of consolidated **Mobile Services** package. -- **Native UI**: Alerts, sheets, and toasts for iOS/Android, plus `NativeUiService.RequestReview()` (iOS `SKStoreReviewController` + Android Play Core In-App Review) and `NativeUiService.Share(text, url, imagePath, title)` (iOS `UIActivityViewController` + Android `Intent.ACTION_SEND`). +- **Native UI**: Alerts, sheets, and toasts for iOS/Android, Review request and Share button with different networks. - **Notifications**: Comprehensive local and remote notification management. - **Gestures**: Advanced swipe detection with velocity and consistency tracking. -- **iOS Audio Session**: `IIosAudioSessionService.ConfigureForPlayback()` (also exposed via `device.AudioSession` on the unified `IDeviceService`) overrides the iOS silent switch so audio keeps playing. -- **Haptics**: zero-dependency haptic feedback (`IHapticsService`) with 9 preset patterns, custom intensity, time-bounded looping (`PlayPresetDuration(preset, duration)` with `-1`=loop / `0`=natural one-shot / `>0`=loop with auto-stop), and `StopCurrentHaptic()`. iOS `UI*FeedbackGenerator` + Android `VibrationEffect.createWaveform` bridges, no third-party plugin required. -- **`IDeviceService` umbrella facade** exposing `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, and `DeepLink` sub-services through one entry point. Each child is independently registerable for testing. `IBatteryService` includes low-power-mode awareness on iOS (`NSProcessInfoPowerStateDidChangeNotification`) and Android (`PowerManager.isPowerSaveMode`). `ISafeAreaService` ships with a companion `SafeAreaContainer` UI Toolkit element. All event-driven children share a single internal MonoBehaviour host for polling. -- **Permissions** (`IPermissionsService`, also at `device.Permissions`): unified iOS+Android runtime permissions covering Camera, Microphone, Location (when-in-use & always), Photo Library (read-write & add-only), and Notifications. `Task`-based async — no `UniTask` dependency. -- **App Tracking Transparency** (`IAttService`, also at `device.Att`): iOS 14.5+ `ATTrackingManager` bridge for `RequestAuthorizationAsync()` and `CurrentStatus`. **Zero dependency on the deprecation-bound `com.unity.ads.ios-support` package**. Android / Editor / unsupported platforms return `Authorized` (no equivalent restriction). -- **Deep Links** (`IDeepLinkService`, also at `device.DeepLink`): wraps `Application.deepLinkActivated` and adds **cold-start link queueing** — links delivered by the OS at app launch are not lost if the first subscriber attaches after the event has fired. +- **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), `Connectivity`, `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`). +- **Notification Builder**: Fluent API — `service.Schedule().In(...).Title(...).Body(...).Channel(...).Send()`. +- **Mobile Service umbrella**: Single DI registration exposing `NativeUi` / `Notifications` / `Haptics` / `Device`. +- **Native UI instance interface**: `INativeUiService` + `NativeUiServiceInstance` forwarder for mockable consumer code. +- **Mobile Services Explorer**: Dockable editor window with eight tabs and a per-platform haptic envelope graph. +- **Mobile Simulator window**: Truth-mirror that paints platform-shaped mocks (iOS / Android) of every native UI surface the package can trigger. +- **Runtime Simulator Overlay**: Play-mode-only `UIDocument` overlay (opt-in via `Project Settings > GameLovers > Mobile Services > Editor tooling > Enable runtime simulator overlay`) rendering the truth-mirror mocks inside Unity's Game / Simulator view at the simulated device's pixel grid. Composes with Unity's Device Simulator for correct safe-area / scale / `Application.platform` spoofing. +- **Device Simulator Plugin**: `UnityEditor.DeviceSimulation.DeviceSimulatorPlugin` subclass that embeds a slim Mobile Services control panel inside Unity's Device Simulator window. Auto-syncs the simulated platform skin from the selected device profile; while alive, the Explorer's `Render as: iOS | Android` dropdown greys out. +- **Editor Platform Simulator**: Static API for driving device / permission / ATT / deep-link state in editor tests and the Explorer. +- **Project Settings panel**: Per-permission usage descriptions, capability toggles, project scan, and an iOS Privacy Nutrition Label draft generator. +- **Build Postprocessor**: Fail-by-default validation that injects `Info.plist`, `.entitlements`, and Android `mainTemplate.xml` entries on iOS / Android builds. +- **Samples**: Four code-only samples — `MobileServicesPlayground`, `HapticsPalette`, `NotificationsScheduler`, `DeepLinkRouter`. +- **Docs**: Per-subsystem deep-dive references under `docs/` plus editor-tooling guides for the Explorer and build pipeline. ### Changed - Refactored all namespaces to `GameLovers.MobileServices.*`. @@ -26,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Legacy tap detection (replaced by Unity Input System's `TapInteraction`). - Gamepad input management (out of scope for mobile services), use the new input system configuration for that +### Removed +- **Editor-tooling automated tests**: removed the `GameLovers.MobileServices.Editor.Tests` assembly and its five test classes (`EditorPlatformSimulatorTest`, `MobileServicesBuildPostprocessorTest`, `MobileServicesExplorerWindowTest`, `MobileServicesSettingsTest`, `MobileSimulatorWindowTest`), along with the `InternalsVisibleTo("GameLovers.MobileServices.Editor.Tests")` grants on `Runtime/AssemblyInfo.cs` and the now-empty `Editor/AssemblyInfo.cs`. Editor tooling is now validated manually only — see `Tests/AGENTS.md` §1 / §9 for the policy and rationale. + ### Migration This package consolidates three previously separate packages: - `com.gamelovers.nativeui` (v0.2.5) -> `GameLovers.MobileServices.NativeUi` 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..a0be9c1 --- /dev/null +++ b/Editor/Build/MobileServicesBuildPostprocessor.cs @@ -0,0 +1,257 @@ +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 + { + public int callbackOrder => 0; + + public void OnPostprocessBuild(BuildReport report) + { + if (report == null) + { + return; + } + + switch (report.summary.platform) + { + case BuildTarget.iOS: + PostprocessIos(report); + break; + case BuildTarget.Android: + PostprocessAndroid(); + break; + } + } + + // ---- iOS ---- + + private void PostprocessIos(BuildReport report) + { + var settings = MobileServicesSettings.instance; + var scan = MobileServicesScanner.Scan(); + + var missing = settings.GetMissingUsageDescriptions(scan.ReferencedPermissions); + var attMissing = scan.UsesAtt && string.IsNullOrWhiteSpace(settings.GetAttUsageDescriptionEn()); + + if (missing.Count > 0 || attMissing) + { + if (!settings.AllowPlaceholderUsageDescriptions) + { + 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($" - {MobileServicesSettings.GetIosUsageKey(p)} (for AppPermission.{p})"); + } + if (attMissing) + { + sb.AppendLine(" - NSUserTrackingUsageDescription (App Tracking Transparency capability is enabled)"); + } + sb.AppendLine(); + sb.AppendLine("Fix: open Edit > Project Settings > GameLovers > Mobile Services and fill in the missing usage descriptions."); + sb.AppendLine("Or enable 'Allow build with placeholder usage descriptions' for CI / preview builds (Apple will reject those placeholders)."); + throw new BuildFailedException(sb.ToString()); + } + + Debug.LogWarning("[GameLovers.MobileServices] Injecting placeholder usage descriptions because 'Allow build with placeholder usage descriptions' is enabled. Apple WILL reject these on App Store submission."); + foreach (var p in missing) + { + settings.SetUsageDescriptionEn(p, "[GameLovers placeholder — replace before App Store submission]"); + } + if (attMissing) + { + settings.SetAttUsageDescriptionEn("[GameLovers placeholder — replace before App Store submission]"); + } + } + +#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, MobileServicesSettings 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 = MobileServicesSettings.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); + } + } + + if (settings.Capabilities.BackgroundAudio) + { + var bg = rootDict.values.ContainsKey("UIBackgroundModes") + ? rootDict["UIBackgroundModes"].AsArray() + : rootDict.CreateArray("UIBackgroundModes"); + if (!ArrayContains(bg, "audio")) + { + bg.AddString("audio"); + } + } + + 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; + + 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.BackgroundAudio) + { + capability.AddBackgroundModes(BackgroundModesOptions.Audio); + } + 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(); + } + + private static bool ArrayContains(PlistElementArray array, string value) + { + foreach (var element in array.values) + { + if (element != null && element.AsString() == value) return true; + } + return false; + } +#endif + + // ---- Android ---- + + private void PostprocessAndroid() + { + var settings = MobileServicesSettings.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."); + } + + Debug.Log("[GameLovers.MobileServices] Android build: ensure 'com.google.android.play:review:2.0.1' is on the gradle classpath if you call NativeUiService.RequestReview()."); + } + + private static string InsertBeforeApplication(string xml, string snippet) + { + var applicationIndex = xml.IndexOf(" + /// implementation that embeds a slim action-button control + /// panel inside Unity's Device Simulator window (Window > General > Device Simulator). + /// Drives the same broker + + /// API the Explorer tabs use, so designers can iterate on mobile UI surfaces with the simulated + /// phone screen sitting right next to the controls that drive it. + /// + /// + /// Auto-syncs from the selected device profile + /// (via Application.platform, which Unity's Device Simulator spoofs for iOS / Android + /// device picks). While the plugin is alive, + /// flips to true; the Mobile Services Explorer header consumes this flag to grey out its + /// own platform dropdown — when this plugin is hosting the platform skin, the dropdown becomes + /// redundant. + /// Unity auto-discovers DeviceSimulatorPlugin subclasses across all editor + /// assemblies — no attribute, no registration boilerplate is needed. + /// + 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 DefaultDeepLinkUri = "myapp://promo/spring2026"; + + public override string title => "Mobile Services"; + + public override void OnCreate() + { + MobileSimulatorState.IsActivePluginConnected = true; + SyncPlatformFromHost(); + } + + public override void OnDestroy() + { + MobileSimulatorState.IsActivePluginConnected = false; + } + + public override VisualElement OnCreateUI() + { + var root = new VisualElement { name = "mobile-services-plugin-root" }; + LoadStyleSheet(root); + + root.Add(BuildHeader()); + root.Add(BuildNativeUiSection()); + root.Add(BuildNotificationsSection()); + root.Add(BuildDeviceSection()); + root.Add(BuildPermissionsSection()); + root.Add(BuildAttSection()); + root.Add(BuildDeepLinkSection()); + + // Re-sync the platform skin from Unity's Device Simulator on a cheap poll. Using + // DeviceSimulator.deviceChanged would be slightly tidier but its delegate signature + // is documented inconsistently across Unity 6 minor versions; reading Application.platform + // (which the simulator spoofs for iOS / Android device profile picks) is identical in + // outcome and version-agnostic. + root.schedule.Execute(SyncPlatformFromHost).Every(500); + + return root; + } + + /// + /// Reads — Unity's Device Simulator spoofs it to match + /// the selected device profile, so an iPhone pick yields IPhonePlayer and a Pixel + /// pick yields Android. Falls back to the existing + /// when running on a non-mobile editor with no simulator selection. + /// + private static void SyncPlatformFromHost() + { + switch (Application.platform) + { + case RuntimePlatform.IPhonePlayer: + MobileSimulatorState.Platform = SimulatedPlatform.iOS; + break; + case RuntimePlatform.Android: + MobileSimulatorState.Platform = SimulatedPlatform.Android; + break; + } + } + + private static VisualElement BuildHeader() + { + var header = new VisualElement { name = "msp-header" }; + header.AddToClassList("msp-header"); + + var title = new Label("Mobile Services"); + title.AddToClassList("msp-title"); + header.Add(title); + + var note = new Label("Drives the truth-mirror simulator. Pair with the Explorer for full-state diagnostics."); + note.AddToClassList("msp-note"); + header.Add(note); + + var buttonRow = new VisualElement(); + buttonRow.AddToClassList("msp-button-row"); + + var explorerBtn = new Button(() => MobileServicesExplorerWindow.Open()) { text = "Open full Explorer \u2192" }; + explorerBtn.AddToClassList("msp-button"); + buttonRow.Add(explorerBtn); + + var dismissBtn = new Button(EditorPlatformSimulator.DismissAllOverlays) { text = "Dismiss all mocks" }; + dismissBtn.AddToClassList("msp-button"); + dismissBtn.AddToClassList("msp-button-danger"); + buttonRow.Add(dismissBtn); + + header.Add(buttonRow); + return header; + } + + private static VisualElement BuildNativeUiSection() + { + var foldout = new Foldout { text = "Native UI", value = true }; + foldout.AddToClassList("msp-foldout"); + + foldout.Add(MakeActionButton("Alert (modal)", () => PushAlert(isSheet: false))); + foldout.Add(MakeActionButton("Action Sheet", () => PushAlert(isSheet: true))); + foldout.Add(MakeActionButton("Toast (short)", () => MobileSimulatorState.PushToast(new SimulatedToastSpec + { + Message = DefaultToastMessage, + IsLongDuration = false, + }))); + foldout.Add(MakeActionButton("Toast (long)", () => MobileSimulatorState.PushToast(new SimulatedToastSpec + { + Message = DefaultToastMessage, + IsLongDuration = true, + }))); + foldout.Add(MakeActionButton("Share", () => MobileSimulatorState.PushShare(new SimulatedShareSpec + { + Text = DefaultShareText, + Url = DefaultShareUrl, + }))); + foldout.Add(MakeActionButton("Review prompt", MobileSimulatorState.PushReview)); + + return foldout; + } + + private static VisualElement BuildNotificationsSection() + { + var foldout = new Foldout { text = "Notifications", value = true }; + foldout.AddToClassList("msp-foldout"); + + foldout.Add(MakeActionButton("Heads-up banner", () => MobileSimulatorState.PushNotificationBanner(new SimulatedNotificationBannerSpec + { + ChannelName = "Rewards", + Title = DefaultNotificationTitle, + Body = DefaultNotificationBody, + }))); + + return foldout; + } + + private static VisualElement BuildDeviceSection() + { + var foldout = new Foldout { text = "Device state", value = true }; + foldout.AddToClassList("msp-foldout"); + + var lpm = new Toggle("Low-power mode") { value = false }; + lpm.RegisterValueChangedCallback(evt => + { + // No per-service fan-out from the plugin — services are plain CLR objects, not + // UnityEngine.Object subclasses, so FindObjectsByType cannot reach them. The static + // override is still set; the next poll tick on the service surfaces the change. + EditorPlatformSimulator.SetIosLowPowerMode(evt.newValue); + }); + foldout.Add(lpm); + + var connectivityField = new EnumField("Connectivity", NetworkReachability.ReachableViaLocalAreaNetwork); + connectivityField.RegisterValueChangedCallback(evt => + { + EditorPlatformSimulator.SetConnectivity((NetworkReachability)evt.newValue); + }); + foldout.Add(connectivityField); + + return foldout; + } + + private static VisualElement BuildPermissionsSection() + { + var foldout = new Foldout { text = "Permissions", value = false }; + foldout.AddToClassList("msp-foldout"); + + var picker = new EnumField("Permission", AppPermission.Camera); + foldout.Add(picker); + + var showBtn = MakeActionButton("Show OS prompt", () => + { + var p = (AppPermission)picker.value; + MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec + { + TypeName = p.ToString(), + UsageDescription = MobileServicesSettings.instance.GetUsageDescriptionEn(p), + IsAtt = false, + OnResolved = null, + }); + }); + foldout.Add(showBtn); + + var queuedResult = new EnumField("Queue next request result", PermissionStatus.Granted); + queuedResult.RegisterValueChangedCallback(evt => + { + EditorPlatformSimulator.QueuePermissionResult((AppPermission)picker.value, (PermissionStatus)evt.newValue); + }); + foldout.Add(queuedResult); + + return foldout; + } + + private static VisualElement BuildAttSection() + { + var foldout = new Foldout { text = "App Tracking Transparency", value = false }; + foldout.AddToClassList("msp-foldout"); + + foldout.Add(MakeActionButton("Show ATT prompt", () => + { + MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec + { + TypeName = "Tracking", + UsageDescription = MobileServicesSettings.instance.GetAttUsageDescriptionEn(), + IsAtt = true, + OnResolved = null, + }); + })); + + var queuedResult = new EnumField("Queue next request result", AttStatus.Authorized); + queuedResult.RegisterValueChangedCallback(evt => + { + EditorPlatformSimulator.QueueAttResult((AttStatus)evt.newValue); + }); + foldout.Add(queuedResult); + + return foldout; + } + + private static VisualElement BuildDeepLinkSection() + { + var foldout = new Foldout { text = "Deep links", value = false }; + foldout.AddToClassList("msp-foldout"); + + var uriField = new TextField("URI") { value = DefaultDeepLinkUri }; + foldout.Add(uriField); + + foldout.Add(MakeActionButton("Send test link", () => + { + if (!Uri.TryCreate(uriField.value, UriKind.Absolute, out var uri)) + { + Debug.LogWarning($"[Mobile Services] Invalid deep-link URI: {uriField.value}"); + return; + } + // DeepLinkService is a plain CLR class, not a UnityEngine.Object — the plugin has + // no way to discover live instances. Logging keeps the action discoverable while + // being honest about the limitation; consumers with a service-locator can wire in + // their own bridge if they need this to drive the live runtime service. + Debug.Log($"[Mobile Services] Deep link send requested: {uri} (the plugin cannot reach a live DeepLinkService instance; call EditorPlatformSimulator.SimulateDeepLink(uri, service) from your bootstrap to deliver)."); + })); + + return foldout; + } + + private static Button MakeActionButton(string text, Action onClick) + { + var btn = new Button(onClick) { text = text }; + btn.AddToClassList("msp-button"); + return btn; + } + + private static void PushAlert(bool isSheet) + { + MobileSimulatorState.PushAlert(new SimulatedAlertSpec + { + Title = DefaultAlertTitle, + Message = DefaultAlertMessage, + IsActionSheet = isSheet, + Buttons = new List + { + new SimulatedAlertButton { Text = "Cancel", Style = SimulatedAlertButtonStyle.Cancel }, + new SimulatedAlertButton { Text = "Delete", Style = SimulatedAlertButtonStyle.Destructive }, + }, + }); + } + + 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..5697fb5 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulator.Android.uss @@ -0,0 +1,94 @@ +.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; +} + +.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: rgba(30, 30, 36, 0.95); + border-radius: 14px; + border-left-width: 4px; + border-color: rgb(120, 180, 255); +} + +.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..fbb4b18 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulator.Common.uss @@ -0,0 +1,225 @@ +.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-bottom: 12px; +} + +.mock-review-star { + color: rgb(255, 190, 50); + font-size: 28px; + margin: 0 2px; +} + +.mock-permission-card { +} + +.mock-notif-card { + padding: 10px 12px; + border-radius: 12px; +} + +.mock-notif-channel { + font-size: 9px; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 2px; +} + +.mock-notif-title { + color: white; + -unity-font-style: bold; + font-size: 12px; +} + +.mock-notif-subtitle { + color: rgba(255, 255, 255, 0.85); + font-size: 11px; +} + +.mock-notif-body { + color: rgba(255, 255, 255, 0.9); + font-size: 11px; + margin-top: 2px; + 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..5c3282e --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulator.iOS.uss @@ -0,0 +1,97 @@ +.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(45, 45, 55, 0.92); + border-radius: 14px; +} 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..8b33a52 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulatorRuntimeOverlay.cs @@ -0,0 +1,303 @@ +using GameLovers.MobileServices.Editor.Settings; +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 during + /// play mode, painting the same truth-mirror mocks as but + /// pixel-aligned with the simulated device's Screen.* values. Opt-in via Project + /// Settings → GameLovers → Mobile Services → Enable runtime simulator overlay. + /// + /// + /// The overlay renders inside Unity's runtime UIToolkit panel, so it composes natively + /// with Unity's Device Simulator: a designer can pick "iPhone 15 Pro" in Window > General > + /// Device Simulator, press Play, and the mock dialogs render at the right scale and inside + /// the correct safe-area inset for that device. + /// The instance is constructed programmatically (rather than + /// shipped as a .asset) to keep the setup editor-only by construction — there's no asset + /// file consumers can accidentally reference from a runtime UIDocument. + /// Lifecycle: spawned on , destroyed on + /// (clean teardown — no paused-snapshot + /// preservation). + /// + [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; + + static MobileSimulatorRuntimeOverlay() + { + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + switch (change) + { + case PlayModeStateChange.EnteredPlayMode: + if (MobileServicesSettings.instance.EnableRuntimeSimulatorOverlay) + { + Spawn(); + } + break; + case PlayModeStateChange.ExitingPlayMode: + Teardown(); + break; + } + } + + private static void Spawn() + { + if (_hostObject != null) + { + return; + } + + 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; + panelSettings.scaleMode = PanelScaleMode.ConstantPixelSize; + panelSettings.match = 0f; + panelSettings.targetTexture = null; + panelSettings.clearColor = false; + panelSettings.hideFlags = HideFlags.HideAndDontSave; + + _hostObject = new GameObject(HostObjectName) + { + hideFlags = HideFlags.DontSave, + tag = "EditorOnly", + }; + 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) + { + Object.Destroy(_hostObject); + _hostObject = null; + } + } + + /// + /// Owns the visual tree + the subscriptions for the + /// runtime overlay. Mirrors 's renderer surface so the + /// same broker payload paints identically in both targets. + /// + private sealed class OverlayController + { + private readonly VisualElement _root; + private readonly VisualElement _stage; + 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 standalone window's opaque 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); + + var 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); + + 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.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 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..633d135 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulatorState.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Overlays +{ + /// + /// Platform skin currently selected by the Mobile Services Explorer's top-row toggle. + /// Drives the truth-mirror overlay's USS swap (iOS / Android). + /// + 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 Explorer tabs and EditorPlatformSimulator from the + /// truth-mirror window that paints the mock dialogs. See docs/explorer.md. + /// + public static class MobileSimulatorState + { + private const string PlatformPrefKey = "GameLovers.MobileServicesExplorer.SimulatedPlatform"; + + private static SimulatedPlatform _platform = SimulatedPlatform.iOS; + private static bool _initialized; + private static bool _isPluginConnected; + + // ---- Platform ---- + + /// Fires when the user flips the Explorer's Render as: iOS | Android toggle. + public static event Action PlatformChanged; + + // ---- DeviceSimulator plugin presence ---- + + /// + /// Fires when a MobileServicesDeviceSimulatorPlugin instance is created or destroyed + /// inside Unity's Device Simulator window. Consumers (e.g. the Explorer header) use this to + /// hand control of the platform skin to the plugin (which auto-syncs from the device profile) + /// instead of letting the user pick it manually. + /// + public static event Action PluginConnectedChanged; + + /// + /// true while at least one MobileServicesDeviceSimulatorPlugin is alive (the + /// user has Unity's Simulator view open with our plugin enabled in it). + /// + public static bool IsActivePluginConnected + { + get => _isPluginConnected; + internal set + { + if (_isPluginConnected == value) + { + return; + } + _isPluginConnected = value; + PluginConnectedChanged?.Invoke(value); + } + } + + 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); + } + } +} 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/MobileSimulatorWindow.cs b/Editor/Explorer/Overlays/MobileSimulatorWindow.cs new file mode 100644 index 0000000..a10ed74 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulatorWindow.cs @@ -0,0 +1,219 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Overlays +{ + /// + /// Truth-mirror editor window that paints platform-shaped mocks of the native UI surfaces the + /// package triggers. Paired with ; see + /// docs/explorer.md for the comparison with Unity's Device Simulator. + /// + public class MobileSimulatorWindow : EditorWindow + { + private const string CommonStyleName = "MobileSimulator.Common"; + private const string IosStyleName = "MobileSimulator.iOS"; + private const string AndroidStyleName = "MobileSimulator.Android"; + private const float MinWidth = 360f; + private const float MinHeight = 640f; + + private VisualElement _stage; + private VisualElement _watermark; + private Label _platformLabel; + private StyleSheet _commonSheet; + private StyleSheet _iosSheet; + private StyleSheet _androidSheet; + + [MenuItem("Tools/GameLovers/Mobile Services Simulator Window")] + public static MobileSimulatorWindow Open() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Mobile Simulator"); + window.minSize = new Vector2(MinWidth, MinHeight); + window.Show(); + return window; + } + + private void CreateGUI() + { + rootVisualElement.style.flexGrow = 1; + + _commonSheet = FindStyleSheet(CommonStyleName); + _iosSheet = FindStyleSheet(IosStyleName); + _androidSheet = FindStyleSheet(AndroidStyleName); + if (_commonSheet != null) + { + rootVisualElement.styleSheets.Add(_commonSheet); + } + + var root = new VisualElement { name = "simulator-root" }; + root.AddToClassList("simulator-root"); + rootVisualElement.Add(root); + + _stage = new VisualElement { name = "simulator-stage" }; + _stage.AddToClassList("simulator-stage"); + root.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); + root.Add(_watermark); + + ApplyPlatformSheet(MobileSimulatorState.Platform); + + 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 void OnDisable() + { + 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 OnPlatformChanged(SimulatedPlatform platform) + { + ApplyPlatformSheet(platform); + } + + private void ApplyPlatformSheet(SimulatedPlatform platform) + { + if (_iosSheet != null && rootVisualElement.styleSheets.Contains(_iosSheet)) + { + rootVisualElement.styleSheets.Remove(_iosSheet); + } + if (_androidSheet != null && rootVisualElement.styleSheets.Contains(_androidSheet)) + { + rootVisualElement.styleSheets.Remove(_androidSheet); + } + + var active = platform == SimulatedPlatform.iOS ? _iosSheet : _androidSheet; + if (active != null) + { + rootVisualElement.styleSheets.Add(active); + } + + rootVisualElement.RemoveFromClassList("platform-ios"); + rootVisualElement.RemoveFromClassList("platform-android"); + rootVisualElement.AddToClassList(platform == SimulatedPlatform.iOS ? "platform-ios" : "platform-android"); + + if (_platformLabel != null) + { + _platformLabel.text = platform.ToString(); + } + } + + // ---- Payload renderers ---- + + private void OnAlert(SimulatedAlertSpec spec) + { + ClearStage(); + var dialog = MockBuilders.BuildAlert(MobileSimulatorState.Platform, spec, dismissCallback: ClearStage); + _stage.Add(dialog); + } + + private void OnToast(SimulatedToastSpec spec) + { + ClearStage(); + var toast = MockBuilders.BuildToast(MobileSimulatorState.Platform, spec); + _stage.Add(toast); + + // Toasts dismiss themselves on a real device — auto-clear the mock after the matching delay. + // EditorWindow doesn't expose `schedule`; route through the rootVisualElement so the + // scheduler is the panel's own (lives as long as the window). + var seconds = spec.IsLongDuration ? 3.5f : 2.0f; + rootVisualElement.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, dismissCallback: ClearStage)); + } + + private void OnReview() + { + ClearStage(); + _stage.Add(MockBuilders.BuildReviewPrompt(MobileSimulatorState.Platform, dismissCallback: ClearStage)); + } + + private void OnNotificationBanner(SimulatedNotificationBannerSpec spec) + { + ClearStage(); + var banner = MockBuilders.BuildNotificationBanner(MobileSimulatorState.Platform, spec); + _stage.Add(banner); + rootVisualElement.schedule.Execute(() => + { + if (_stage.Contains(banner)) + { + _stage.Remove(banner); + } + }).StartingIn(4000); + } + + private void OnPermissionDialog(SimulatedPermissionDialogSpec spec) + { + ClearStage(); + var dialog = MockBuilders.BuildPermissionDialog(MobileSimulatorState.Platform, spec, dismissCallback: result => + { + ClearStage(); + spec.OnResolved?.Invoke(result); + }); + _stage.Add(dialog); + } + + private void OnDismissAll() + { + ClearStage(); + } + + private void ClearStage() + { + if (_stage == null) + { + return; + } + + // Watermark cannot be cleared. It's not a child of the stage. + _stage.Clear(); + } + } +} diff --git a/Editor/Explorer/Overlays/MobileSimulatorWindow.cs.meta b/Editor/Explorer/Overlays/MobileSimulatorWindow.cs.meta new file mode 100644 index 0000000..a1c4cc0 --- /dev/null +++ b/Editor/Explorer/Overlays/MobileSimulatorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09d2cf111b21e4c8fb3748c7b1c4cae6 \ 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..ff516cf --- /dev/null +++ b/Editor/Explorer/Overlays/MockBuilders.cs @@ -0,0 +1,320 @@ +using System; +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 ---- + + internal static VisualElement BuildReviewPrompt(SimulatedPlatform platform, Action dismissCallback) + { + var scrim = new VisualElement(); + scrim.AddToClassList("mock-scrim"); + + var card = new VisualElement(); + card.AddToClassList("mock-card"); + card.AddToClassList("mock-review-card"); + + var title = new Label(platform == SimulatedPlatform.iOS + ? "Enjoying this app?" + : "Was this app helpful?"); + 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 stars = new VisualElement(); + stars.AddToClassList("mock-review-stars"); + for (var i = 0; i < 5; i++) + { + var star = new Label("\u2606"); + star.AddToClassList("mock-review-star"); + stars.Add(star); + } + card.Add(stars); + + var buttons = new VisualElement(); + buttons.AddToClassList("mock-card-button-row"); + buttons.AddToClassList("mock-card-button-row-horizontal"); + + var cancel = new Button(() => dismissCallback?.Invoke()) { text = "Not Now" }; + cancel.AddToClassList("mock-card-button"); + cancel.AddToClassList("mock-card-button-cancel"); + buttons.Add(cancel); + + var submit = new Button(() => dismissCallback?.Invoke()) { text = "Submit" }; + submit.AddToClassList("mock-card-button"); + buttons.Add(submit); + + card.Add(buttons); + scrim.Add(card); + return scrim; + } + + // ---- Permission / ATT dialog ---- + + internal static VisualElement BuildPermissionDialog(SimulatedPlatform platform, SimulatedPermissionDialogSpec spec, Action dismissCallback) + { + var scrim = new VisualElement(); + scrim.AddToClassList("mock-scrim"); + + var card = new VisualElement(); + card.AddToClassList("mock-card"); + card.AddToClassList("mock-permission-card"); + + var title = new Label(BuildPermissionTitle(spec)); + title.AddToClassList("mock-card-title"); + card.Add(title); + + var message = new Label(string.IsNullOrEmpty(spec.UsageDescription) + ? "(no usage description configured — set one in Project Settings > GameLovers > Mobile Services)" + : spec.UsageDescription); + message.AddToClassList("mock-card-message"); + if (string.IsNullOrEmpty(spec.UsageDescription)) + { + message.AddToClassList("mock-card-message-warning"); + } + card.Add(message); + + var buttons = new VisualElement(); + buttons.AddToClassList("mock-card-button-row"); + buttons.AddToClassList("mock-card-button-row-horizontal"); + + var denyText = spec.IsAtt ? "Ask App Not to Track" : "Don't Allow"; + var allowText = spec.IsAtt ? "Allow" : (platform == SimulatedPlatform.iOS ? "OK" : "Allow"); + + if (platform == SimulatedPlatform.iOS) + { + buttons.Add(MakeBtn(denyText, false, dismissCallback, "mock-card-button-cancel")); + buttons.Add(MakeBtn(allowText, true, dismissCallback, null)); + } + else + { + buttons.Add(MakeBtn(denyText, false, dismissCallback, "mock-card-button-cancel")); + buttons.Add(MakeBtn(allowText, true, dismissCallback, null)); + } + + card.Add(buttons); + scrim.Add(card); + return scrim; + } + + private static Button MakeBtn(string text, bool result, Action dismissCallback, string extraClass) + { + var btn = new Button(() => dismissCallback?.Invoke(result)) { text = text }; + btn.AddToClassList("mock-card-button"); + if (!string.IsNullOrEmpty(extraClass)) + { + btn.AddToClassList(extraClass); + } + return btn; + } + + private static string BuildPermissionTitle(SimulatedPermissionDialogSpec spec) + { + if (spec.IsAtt) + { + return "Allow this app to track your activity across other companies' apps and websites?"; + } + return $"\"YourApp\" Would Like to Access Your {spec.TypeName}"; + } + + // ---- Heads-up notification banner ---- + + internal static VisualElement BuildNotificationBanner(SimulatedPlatform platform, SimulatedNotificationBannerSpec spec) + { + var wrapper = new VisualElement(); + wrapper.AddToClassList(platform == SimulatedPlatform.iOS ? "mock-notif-top-ios" : "mock-notif-top-android"); + wrapper.pickingMode = PickingMode.Ignore; + + var card = new VisualElement(); + card.AddToClassList("mock-notif-card"); + + if (!string.IsNullOrEmpty(spec.ChannelName)) + { + var chan = new Label(spec.ChannelName.ToUpperInvariant()); + chan.AddToClassList("mock-notif-channel"); + card.Add(chan); + } + + var title = new Label(spec.Title ?? string.Empty); + title.AddToClassList("mock-notif-title"); + card.Add(title); + + if (!string.IsNullOrEmpty(spec.SubTitle)) + { + var subtitle = new Label(spec.SubTitle); + subtitle.AddToClassList("mock-notif-subtitle"); + card.Add(subtitle); + } + + var body = new Label(spec.Body ?? string.Empty); + body.AddToClassList("mock-notif-body"); + card.Add(body); + + wrapper.Add(card); + return wrapper; + } + } +} diff --git a/Editor/Explorer/Overlays/MockBuilders.cs.meta b/Editor/Explorer/Overlays/MockBuilders.cs.meta new file mode 100644 index 0000000..2ffe5c0 --- /dev/null +++ b/Editor/Explorer/Overlays/MockBuilders.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d98d29bdbecd44a7a4607e3f0970fe6 \ No newline at end of file diff --git a/Editor/Explorer/Tabs.meta b/Editor/Explorer/Tabs.meta new file mode 100644 index 0000000..b73d652 --- /dev/null +++ b/Editor/Explorer/Tabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a1bff86796d79475aba67e0e9d403064 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Explorer/Tabs/AttDeepLinkTab.cs b/Editor/Explorer/Tabs/AttDeepLinkTab.cs new file mode 100644 index 0000000..0b903f7 --- /dev/null +++ b/Editor/Explorer/Tabs/AttDeepLinkTab.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using GameLovers.MobileServices.Editor.Simulation; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Combined App Tracking Transparency + Deep Link inspector tab. + public sealed class AttDeepLinkTab : MobileServiceTab + { + public override string DisplayName => "ATT + Deep Link"; + protected override int RefreshIntervalMs => 500; + + private readonly AttService _att = new AttService(); + private DeepLinkService _deepLink; + private Label _attStatus; + private DropdownField _attResultDropdown; + private Label _coldStartLabel; + private Label _lastDeliveredLabel; + private TextField _deepLinkInput; + private Uri _lastDelivered; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + scroll.Add(MakeSectionLabel("App Tracking Transparency")); + _attStatus = new Label(); + scroll.Add(_attStatus); + + var attRow = new VisualElement(); + attRow.style.flexDirection = FlexDirection.Row; + attRow.Add(MakeRowButton("Check", () => Refresh())); + attRow.Add(MakeRowButton("Request", () => _ = RequestAttAsync())); + attRow.Add(MakeRowButton("Show Mock", () => + { + MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec + { + TypeName = "App Tracking", + UsageDescription = "(set NSUserTrackingUsageDescription in Project Settings)", + IsAtt = true, + OnResolved = result => QueueAttFromMock(result), + }); + })); + scroll.Add(attRow); + + _attResultDropdown = new DropdownField("Simulate next", new List + { + "(no override)", + AttStatus.Authorized.ToString(), + AttStatus.Denied.ToString(), + AttStatus.Restricted.ToString(), + AttStatus.NotDetermined.ToString(), + }, 0); + _attResultDropdown.RegisterValueChangedCallback(evt => + { + if (evt.newValue == "(no override)") + { + EditorPlatformSimulator.QueueAttResult(null); + } + else if (Enum.TryParse(evt.newValue, out var parsed)) + { + EditorPlatformSimulator.QueueAttResult(parsed); + } + }); + scroll.Add(_attResultDropdown); + + scroll.Add(MakeSectionLabel("Deep Links")); + _coldStartLabel = new Label(); + _lastDeliveredLabel = new Label(); + scroll.Add(_coldStartLabel); + scroll.Add(_lastDeliveredLabel); + + _deepLinkInput = new TextField("URI") { value = "myapp://promo/spring2026" }; + scroll.Add(_deepLinkInput); + + var dlRow = new VisualElement(); + dlRow.style.flexDirection = FlexDirection.Row; + dlRow.Add(MakePrimaryButton("Send test link", SendTestLink)); + dlRow.Add(MakePrimaryButton("Initialise DeepLinkService", InitialiseDeepLinkService)); + scroll.Add(dlRow); + + Add(scroll); + Refresh(); + } + + protected override void Refresh() + { + _attStatus.text = $"ATT current status: {_att.CurrentStatus}"; + + if (_deepLink == null) + { + _coldStartLabel.text = "Pending cold-start link: (DeepLinkService not initialised)"; + _lastDeliveredLabel.text = string.Empty; + return; + } + _coldStartLabel.text = _deepLink.PendingColdStartLink != null + ? $"Pending cold-start link: {_deepLink.PendingColdStartLink}" + : "Pending cold-start link: (none)"; + _lastDeliveredLabel.text = _lastDelivered != null + ? $"Last delivered: {_lastDelivered}" + : "Last delivered: (none)"; + } + + protected override void OnExitingPlayMode() + { + _deepLink?.Dispose(); + _deepLink = null; + _lastDelivered = null; + } + + private async System.Threading.Tasks.Task RequestAttAsync() + { + var result = await _att.RequestAuthorizationAsync(); + _attStatus.text = $"ATT request result: {result}"; + } + + private void QueueAttFromMock(bool authorized) + { + EditorPlatformSimulator.QueueAttResult(authorized ? AttStatus.Authorized : AttStatus.Denied); + } + + private void InitialiseDeepLinkService() + { + if (_deepLink != null) return; + _deepLink = new DeepLinkService(); + _deepLink.OnLinkActivated += uri => + { + _lastDelivered = uri; + Refresh(); + }; + Refresh(); + } + + private void SendTestLink() + { + InitialiseDeepLinkService(); + if (Uri.TryCreate(_deepLinkInput.value, UriKind.Absolute, out var uri)) + { + EditorPlatformSimulator.SimulateDeepLink(uri, _deepLink); + } + else + { + Debug.LogWarning("[MobileServicesExplorer] Invalid URI: " + _deepLinkInput.value); + } + } + } +} diff --git a/Editor/Explorer/Tabs/AttDeepLinkTab.cs.meta b/Editor/Explorer/Tabs/AttDeepLinkTab.cs.meta new file mode 100644 index 0000000..2b7e58b --- /dev/null +++ b/Editor/Explorer/Tabs/AttDeepLinkTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3edd14914bc4b498290462cb3e2aad2c \ No newline at end of file diff --git a/Editor/Explorer/Tabs/DeviceTab.cs b/Editor/Explorer/Tabs/DeviceTab.cs new file mode 100644 index 0000000..cbe75ad --- /dev/null +++ b/Editor/Explorer/Tabs/DeviceTab.cs @@ -0,0 +1,144 @@ +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.Simulation; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Device tab — live battery / connectivity / safe area / LPM plus simulator overrides. + public sealed class DeviceTab : MobileServiceTab + { + public override string DisplayName => "Device"; + protected override int RefreshIntervalMs => 500; + + private DeviceService _device; + private Label _battery; + private Label _battStatus; + private Label _lpm; + private Label _connectivity; + private Label _safeArea; + private Label _screenWake; + private Toggle _lpmToggle; + private DropdownField _reachabilityDropdown; + private FloatField _safeAreaInsetTop; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + scroll.Add(MakeSectionLabel("Live state")); + _battery = new Label(); scroll.Add(_battery); + _battStatus = new Label(); scroll.Add(_battStatus); + _lpm = new Label(); scroll.Add(_lpm); + _connectivity = new Label(); scroll.Add(_connectivity); + _safeArea = new Label(); scroll.Add(_safeArea); + _screenWake = new Label(); scroll.Add(_screenWake); + + scroll.Add(MakeSectionLabel("Simulator (Play mode)")); + _lpmToggle = new Toggle("Low Power Mode") { value = false }; + _lpmToggle.RegisterValueChangedCallback(evt => + { + if (_device == null) return; + EditorPlatformSimulator.SetIosLowPowerMode(evt.newValue, _device.Battery as BatteryService); + }); + scroll.Add(_lpmToggle); + + _reachabilityDropdown = new DropdownField("Connectivity", + new System.Collections.Generic.List + { + NetworkReachability.NotReachable.ToString(), + NetworkReachability.ReachableViaCarrierDataNetwork.ToString(), + NetworkReachability.ReachableViaLocalAreaNetwork.ToString(), + }, 0); + _reachabilityDropdown.RegisterValueChangedCallback(evt => + { + if (_device == null) return; + if (System.Enum.TryParse(evt.newValue, out var parsed)) + { + EditorPlatformSimulator.SetConnectivity(parsed, _device.Connectivity as ConnectivityService); + } + }); + scroll.Add(_reachabilityDropdown); + + var safeAreaRow = new VisualElement(); + safeAreaRow.style.flexDirection = FlexDirection.Row; + safeAreaRow.Add(new Label("Notch inset (top px)")); + _safeAreaInsetTop = new FloatField { value = 0f }; + _safeAreaInsetTop.style.flexGrow = 1; + _safeAreaInsetTop.style.marginLeft = 8; + safeAreaRow.Add(_safeAreaInsetTop); + scroll.Add(safeAreaRow); + + var applySafeArea = new Button(() => + { + if (_device == null) return; + var inset = Mathf.Max(0f, _safeAreaInsetTop.value); + var rect = new Rect(0f, 0f, Screen.width, Mathf.Max(1f, Screen.height - inset)); + EditorPlatformSimulator.SetSafeArea(rect, _device.SafeArea as SafeAreaService); + }) { text = "Apply notch inset" }; + applySafeArea.AddToClassList("action-primary"); + scroll.Add(applySafeArea); + + var clearSafeArea = new Button(() => + { + if (_device == null) return; + EditorPlatformSimulator.ClearSafeAreaOverride(_device.SafeArea as SafeAreaService); + }) { text = "Clear safe-area override" }; + scroll.Add(clearSafeArea); + + var bar = MakeActionBar(); + bar.Add(MakePrimaryButton("Initialise DeviceService", InitialiseService)); + bar.Add(MakePrimaryDangerButton("Dispose", DisposeService)); + scroll.Add(bar); + + Add(scroll); + } + + protected override void Refresh() + { + if (_device == null) + { + _battery.text = "Battery: (Initialise to start polling)"; + _battStatus.text = string.Empty; + _lpm.text = string.Empty; + _connectivity.text = string.Empty; + _safeArea.text = $"Screen.safeArea: {Screen.safeArea}"; + _screenWake.text = string.Empty; + return; + } + + _battery.text = $"Battery: {_device.Battery.Level:P0}"; + _battStatus.text = $"Status: {_device.Battery.Status}"; + _lpm.text = $"Low Power Mode: {_device.Battery.IsLowPowerMode}"; + _connectivity.text = $"Connectivity: {_device.Connectivity.Status}"; + _safeArea.text = $"Safe area: {_device.SafeArea.SafeArea}"; + _screenWake.text = $"KeepAwake: {_device.ScreenWake.KeepAwake}"; + } + + protected override void OnExitingPlayMode() + { + DisposeService(); + } + + private void InitialiseService() + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] DeviceService spawns a DontDestroyOnLoad host — requires Play mode."); + return; + } + if (_device != null) return; + _device = new DeviceService(); + Refresh(); + } + + private void DisposeService() + { + _device?.Dispose(); + _device = null; + Refresh(); + } + } +} diff --git a/Editor/Explorer/Tabs/DeviceTab.cs.meta b/Editor/Explorer/Tabs/DeviceTab.cs.meta new file mode 100644 index 0000000..1e88390 --- /dev/null +++ b/Editor/Explorer/Tabs/DeviceTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ec913d7a89f4433fb9cc18bc583b30f \ No newline at end of file diff --git a/Editor/Explorer/Tabs/GesturesTab.cs b/Editor/Explorer/Tabs/GesturesTab.cs new file mode 100644 index 0000000..31f7327 --- /dev/null +++ b/Editor/Explorer/Tabs/GesturesTab.cs @@ -0,0 +1,113 @@ +using GameLovers.MobileServices.Gestures; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Gestures tab — auto-subscribes to a scene and shows last swipe/tap metrics. + public sealed class GesturesTab : MobileServiceTab + { + public override string DisplayName => "Gestures"; + protected override int RefreshIntervalMs => 500; + + private Label _swipeLabel; + private Label _tapLabel; + private Label _statusLabel; + private GestureController _attached; + private SwipeInput _lastSwipe; + private TapInput _lastTap; + private bool _hasSwipe; + private bool _hasTap; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + _statusLabel = new Label("Searching for GestureController in scene…"); + scroll.Add(_statusLabel); + + scroll.Add(MakeSectionLabel("Last swipe")); + _swipeLabel = new Label("(none)"); + scroll.Add(_swipeLabel); + + scroll.Add(MakeSectionLabel("Last tap")); + _tapLabel = new Label("(none)"); + scroll.Add(_tapLabel); + + var bar = MakeActionBar(); + bar.Add(MakePrimaryDangerButton("Reset", () => + { + _hasSwipe = false; + _hasTap = false; + _swipeLabel.text = "(none)"; + _tapLabel.text = "(none)"; + })); + scroll.Add(bar); + + Add(scroll); + } + + protected override void Refresh() + { + var controller = Application.isPlaying + ? Object.FindFirstObjectByType() + : null; + + if (controller != _attached) + { + if (_attached != null) + { + _attached.Swiped -= OnSwiped; + _attached.Tapped -= OnTapped; + } + _attached = controller; + if (_attached != null) + { + _attached.Swiped += OnSwiped; + _attached.Tapped += OnTapped; + } + } + + _statusLabel.text = _attached != null + ? $"Attached to {_attached.gameObject.name}" + : Application.isPlaying + ? "No GestureController found in scene." + : "Enter Play mode to scan for GestureController."; + + if (_hasSwipe) + { + _swipeLabel.text = $"dir={_lastSwipe.SwipeDirection}, vel={_lastSwipe.SwipeVelocity:F1}, sameness={_lastSwipe.SwipeSameness:F2}, start={_lastSwipe.StartPosition}, end={_lastSwipe.EndPosition}"; + } + if (_hasTap) + { + _tapLabel.text = $"press={_lastTap.PressPosition}, release={_lastTap.ReleasePosition}, duration={_lastTap.TapDuration:F3}s"; + } + } + + protected override void OnExitingPlayMode() + { + if (_attached != null) + { + _attached.Swiped -= OnSwiped; + _attached.Tapped -= OnTapped; + } + _attached = null; + _hasSwipe = false; + _hasTap = false; + } + + private void OnSwiped(SwipeInput swipe) + { + _lastSwipe = swipe; + _hasSwipe = true; + } + + private void OnTapped(TapInput tap) + { + _lastTap = tap; + _hasTap = true; + } + } +} diff --git a/Editor/Explorer/Tabs/GesturesTab.cs.meta b/Editor/Explorer/Tabs/GesturesTab.cs.meta new file mode 100644 index 0000000..5984d02 --- /dev/null +++ b/Editor/Explorer/Tabs/GesturesTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd5adf08b990b447b9100e09fb259c2d \ No newline at end of file diff --git a/Editor/Explorer/Tabs/HapticsTab.cs b/Editor/Explorer/Tabs/HapticsTab.cs new file mode 100644 index 0000000..9a4db9e --- /dev/null +++ b/Editor/Explorer/Tabs/HapticsTab.cs @@ -0,0 +1,212 @@ +using System; +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.Haptics.Internal; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Haptics tab — preset buttons + custom intensity + per-preset envelope graph. + public sealed class HapticsTab : MobileServiceTab + { + public override string DisplayName => "Haptics"; + protected override int RefreshIntervalMs => 500; + + private HapticsService _haptics; + private HapticPreset _previewPreset = HapticPreset.Selection; + private Label _statusLabel; + private VisualElement _envelopeCanvas; + private Slider _intensitySlider; + private FloatField _durationField; + private Label _intensityValueLabel; + private Label _durationValueLabel; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + _statusLabel = new Label(); + scroll.Add(_statusLabel); + + scroll.Add(MakeSectionLabel("Preset")); + 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; + EnsureHaptics().PlayPreset(captured); + RebuildEnvelope(); + RefreshStatus(); + }) { text = preset.ToString() }; + btn.style.minWidth = 80; + btn.style.marginRight = 4; + btn.style.marginBottom = 4; + presetGrid.Add(btn); + } + scroll.Add(presetGrid); + + scroll.Add(MakeSectionLabel("Envelope (timings ms × amplitudes 0..255)")); + _envelopeCanvas = new VisualElement(); + _envelopeCanvas.AddToClassList("haptic-envelope-canvas"); + scroll.Add(_envelopeCanvas); + + scroll.Add(MakeSectionLabel("Custom")); + // Use a side-mounted label + naked slider so the label width pitfall doesn't eat space. + var intensityRow = new VisualElement(); + intensityRow.style.flexDirection = FlexDirection.Row; + intensityRow.style.alignItems = Align.Center; + intensityRow.Add(new Label("Intensity (0..1)")); + _intensitySlider = new Slider(0f, 1f) { value = 0.7f }; + _intensitySlider.style.flexGrow = 1; + _intensitySlider.style.marginLeft = 8; + _intensityValueLabel = new Label("0.70"); + _intensityValueLabel.style.minWidth = 40; + _intensityValueLabel.style.marginLeft = 6; + _intensitySlider.RegisterValueChangedCallback(evt => _intensityValueLabel.text = evt.newValue.ToString("F2")); + intensityRow.Add(_intensitySlider); + intensityRow.Add(_intensityValueLabel); + scroll.Add(intensityRow); + + var durationRow = new VisualElement(); + durationRow.style.flexDirection = FlexDirection.Row; + durationRow.style.alignItems = Align.Center; + durationRow.Add(new Label("Duration (ms)")); + _durationField = new FloatField { value = 250f }; + _durationField.style.flexGrow = 1; + _durationField.style.marginLeft = 8; + _durationValueLabel = new Label(); + _durationValueLabel.style.minWidth = 50; + _durationValueLabel.style.marginLeft = 6; + _durationField.RegisterValueChangedCallback(evt => _durationValueLabel.text = $"{evt.newValue:F0} ms"); + _durationValueLabel.text = $"{_durationField.value:F0} ms"; + durationRow.Add(_durationField); + durationRow.Add(_durationValueLabel); + scroll.Add(durationRow); + + var playCustomBtn = new Button(() => + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] PlayCustom requires Play mode — HapticsHost spawns a DontDestroyOnLoad GameObject."); + return; + } + EnsureHaptics().PlayCustom(_intensitySlider.value, _durationField.value); + _previewPreset = HapticPreset.None; + RebuildEnvelope(); + RefreshStatus(); + }) { text = "Play Custom (Play mode only)" }; + playCustomBtn.AddToClassList("action-primary"); + scroll.Add(playCustomBtn); + + scroll.Add(MakeSectionLabel("Looped")); + var loopRow = new VisualElement(); + loopRow.style.flexDirection = FlexDirection.Row; + loopRow.Add(new Button(() => + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] Indefinite loop requires Play mode."); + return; + } + EnsureHaptics().PlayPresetDuration(_previewPreset, -1f); + RefreshStatus(); + }) { text = "Loop (until stop)" }); + loopRow.Add(new Button(() => + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] Timed loop requires Play mode."); + return; + } + EnsureHaptics().PlayPresetDuration(_previewPreset, 0.5f); + RefreshStatus(); + }) { text = "Loop 500ms" }); + scroll.Add(loopRow); + + var bar = MakeActionBar(); + bar.Add(MakePrimaryDangerButton("Stop", () => + { + _haptics?.StopCurrentHaptic(); + RefreshStatus(); + })); + scroll.Add(bar); + + Add(scroll); + RebuildEnvelope(); + RefreshStatus(); + } + + protected override void Refresh() + { + RefreshStatus(); + } + + protected override void OnExitingPlayMode() + { + _haptics?.StopCurrentHaptic(); + _haptics = null; + } + + private HapticsService EnsureHaptics() + { + return _haptics ??= new HapticsService(); + } + + private void RefreshStatus() + { + if (_haptics == null) + { + _statusLabel.text = "Haptics: (none)"; + return; + } + _statusLabel.text = $"Haptics: IsPlaying={_haptics.IsPlaying}, CurrentPreset={_haptics.CurrentPreset}, Duration={_haptics.CurrentDurationSeconds:F2}s, IsSupported={_haptics.IsSupported}"; + } + + private void RebuildEnvelope() + { + _envelopeCanvas.Clear(); + var (timesSec, amps) = HapticEnvelopes.GetFloatEnvelopeFor(_previewPreset); + if (timesSec == null || timesSec.Length == 0) + { + return; + } + + var totalSec = 0f; + for (var i = 0; i < timesSec.Length; i++) + { + totalSec += timesSec[i]; + } + if (totalSec <= 0f) + { + return; + } + + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.FlexEnd; + row.style.flexGrow = 1; + row.style.height = Length.Percent(100); + _envelopeCanvas.Add(row); + + for (var i = 0; i < timesSec.Length; i++) + { + var widthPct = timesSec[i] / totalSec * 100f; + var bar = new VisualElement(); + bar.AddToClassList("haptic-bar"); + bar.style.width = Length.Percent(widthPct); + bar.style.height = Length.Percent(Mathf.Max(2f, amps[i] * 100f)); + bar.style.marginRight = 1; + bar.tooltip = $"{timesSec[i] * 1000f:F0} ms @ {amps[i]:F3} amp"; + row.Add(bar); + } + } + } +} diff --git a/Editor/Explorer/Tabs/HapticsTab.cs.meta b/Editor/Explorer/Tabs/HapticsTab.cs.meta new file mode 100644 index 0000000..83b42d3 --- /dev/null +++ b/Editor/Explorer/Tabs/HapticsTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e92e1373e217e471f88f7cecbdf042d4 \ No newline at end of file diff --git a/Editor/Explorer/Tabs/MobileServiceTab.cs b/Editor/Explorer/Tabs/MobileServiceTab.cs new file mode 100644 index 0000000..95a749d --- /dev/null +++ b/Editor/Explorer/Tabs/MobileServiceTab.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// + /// Abstract base for all Mobile Services Explorer tab panels. Mirrors the ServiceTab + /// pattern in com.gamelovers.services; see AGENTS.md §2 for the contract. + /// + public abstract class MobileServiceTab : VisualElement + { + private const string BannerClass = "tab-banner"; + private const string RootClass = "tab-root"; + private const string EditModeBannerText = "Not in Play mode — showing last snapshot"; + private const string StoppedBannerText = "Play session ended — services unbound"; + + private Label _banner; + private IVisualElementScheduledItem _refreshTask; + private bool _hasSeenPlay; + + private readonly HashSet _collapsedFoldoutKeys = new HashSet(); + private string _lastRefreshDigest; + + /// Tab header text shown in the TabView strip. + public abstract string DisplayName { get; } + + /// Refresh interval in milliseconds during Play mode. Override for slower updates. + protected virtual int RefreshIntervalMs => 250; + + protected MobileServiceTab() + { + AddToClassList(RootClass); + style.flexGrow = 1; + + _banner = new Label(EditModeBannerText); + _banner.AddToClassList(BannerClass); + Add(_banner); + + BuildUi(); + UpdateBannerVisibility(); + + RegisterCallback(OnAttach); + RegisterCallback(OnDetach); + } + + /// Build all child VisualElements. Called once in the constructor after the banner. + protected abstract void BuildUi(); + + /// + /// Pull latest data from services and repopulate UI. Called every ms + /// during Play mode, and once manually on attach (Edit mode snapshot). + /// + protected abstract void Refresh(); + + /// + /// Called synchronously on , BEFORE + /// scene teardown. Subclasses with populated state widgets should override to forcibly + /// clear them — see ServiceTab.OnExitingPlayMode rationale. + /// + protected virtual void OnExitingPlayMode() { } + + private void OnAttach(AttachToPanelEvent _) + { + EditorApplication.playModeStateChanged += OnPlayModeChanged; + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + _hasSeenPlay = true; + } + InvalidateRefreshDigest(); + UpdateBannerVisibility(); + Refresh(); + if (EditorApplication.isPlaying) + { + StartRefreshTimer(); + } + } + + private void OnDetach(DetachFromPanelEvent _) + { + EditorApplication.playModeStateChanged -= OnPlayModeChanged; + StopRefreshTimer(); + } + + private void OnPlayModeChanged(PlayModeStateChange state) + { + switch (state) + { + case PlayModeStateChange.EnteredPlayMode: + _hasSeenPlay = true; + UpdateBannerVisibility(); + InvalidateRefreshDigest(); + Refresh(); + StartRefreshTimer(); + break; + case PlayModeStateChange.ExitingPlayMode: + StopRefreshTimer(); + OnExitingPlayMode(); + UpdateBannerVisibility(); + InvalidateRefreshDigest(); + EditorApplication.delayCall += DelayedExitRefresh; + break; + case PlayModeStateChange.EnteredEditMode: + UpdateBannerVisibility(); + InvalidateRefreshDigest(); + Refresh(); + break; + } + } + + private void DelayedExitRefresh() + { + if (panel == null) + { + return; + } + UpdateBannerVisibility(); + InvalidateRefreshDigest(); + Refresh(); + } + + private void StartRefreshTimer() + { + StopRefreshTimer(); + _refreshTask = schedule.Execute(() => + { + if (panel != null) + { + Refresh(); + } + }).Every(RefreshIntervalMs); + } + + private void StopRefreshTimer() + { + _refreshTask?.Pause(); + _refreshTask = null; + } + + private void UpdateBannerVisibility() + { + if (EditorApplication.isPlaying) + { + _banner.style.display = DisplayStyle.None; + return; + } + _banner.text = _hasSeenPlay ? StoppedBannerText : EditModeBannerText; + _banner.style.display = DisplayStyle.Flex; + } + + // ---- Helpers for sub-classes ---- + + protected static VisualElement MakeRow(string label, string value = null) + { + var row = new VisualElement(); + row.AddToClassList("row"); + var lbl = new Label(label); + lbl.AddToClassList("row-label"); + row.Add(lbl); + if (value != null) + { + var val = new Label(value); + val.AddToClassList("row-value"); + row.Add(val); + } + return row; + } + + protected static Button MakeRowButton(string text, Action onClick, bool danger = false) + { + var btn = new Button(onClick) { text = text }; + btn.AddToClassList("row-btn"); + if (danger) + { + btn.AddToClassList("row-btn-danger"); + } + return btn; + } + + protected static Label MakeSectionLabel(string text) + { + var lbl = new Label(text); + lbl.AddToClassList("tab-section-label"); + return lbl; + } + + protected static Label MakeEmptyLabel(string text = "— none —") + { + var lbl = new Label(text); + lbl.AddToClassList("tab-empty-label"); + return lbl; + } + + protected static VisualElement MakeActionBar() + { + var bar = new VisualElement(); + bar.AddToClassList("action-bar"); + return bar; + } + + protected static Button MakePrimaryButton(string text, Action onClick) + { + var btn = new Button(onClick) { text = text }; + btn.AddToClassList("action-primary"); + return btn; + } + + /// + /// Destructive primary action button styled with action-primary-danger. Use for + /// primary call-to-actions that remove or invalidate state — see workspace + /// "Services Explorer Destructive-Action Styling" rule. + /// + protected static Button MakePrimaryDangerButton(string text, Action onClick) + { + var btn = new Button(onClick) { text = text }; + btn.AddToClassList("action-primary-danger"); + return btn; + } + + /// + /// Returns true when the supplied matches the previous + /// refresh's digest, so the tab can return early without rebuilding. Required for tabs + /// whose Refresh() nukes-and-rebuilds the visual tree, otherwise rapid clicks are + /// eaten by the periodic refresh destroying mouse-captured elements. + /// + protected bool TryShortCircuitRefresh(string digest) + { + if (digest != null && string.Equals(_lastRefreshDigest, digest, StringComparison.Ordinal)) + { + return true; + } + _lastRefreshDigest = digest; + return false; + } + + protected void InvalidateRefreshDigest() + { + _lastRefreshDigest = null; + } + + /// + /// Creates a whose expanded/collapsed state survives the tab's + /// periodic refresh — see workspace "UIToolkit Sticky Foldout" rule. + /// + protected Foldout MakeStickyFoldout(string key, string text, bool defaultExpanded = true) + { + var foldout = new Foldout { text = text }; + var initialValue = defaultExpanded + ? !_collapsedFoldoutKeys.Contains(key) + : _collapsedFoldoutKeys.Contains(key); + foldout.SetValueWithoutNotify(initialValue); + foldout.RegisterValueChangedCallback(evt => + { + // Required filter — ChangeEvent bubbles up the visual tree, so a nested + // Toggle's value change would otherwise mark the ancestor collapsed too. + if (evt.target != foldout) + { + return; + } + if (evt.newValue) + { + _collapsedFoldoutKeys.Remove(key); + } + else + { + _collapsedFoldoutKeys.Add(key); + } + }); + return foldout; + } + } +} diff --git a/Editor/Explorer/Tabs/MobileServiceTab.cs.meta b/Editor/Explorer/Tabs/MobileServiceTab.cs.meta new file mode 100644 index 0000000..5d7fd90 --- /dev/null +++ b/Editor/Explorer/Tabs/MobileServiceTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ede808f26f5e34f7c8f672fa472f1d8e \ No newline at end of file diff --git a/Editor/Explorer/Tabs/NativeUiTab.cs b/Editor/Explorer/Tabs/NativeUiTab.cs new file mode 100644 index 0000000..38184ce --- /dev/null +++ b/Editor/Explorer/Tabs/NativeUiTab.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using GameLovers.MobileServices.NativeUi; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Native UI driver tab — fires the real and the matching simulator mocks. + public sealed class NativeUiTab : MobileServiceTab + { + public override string DisplayName => "Native UI"; + protected override int RefreshIntervalMs => 1000; + + private TextField _alertTitle; + private TextField _alertMessage; + private TextField _toastMessage; + private Toggle _toastLongDuration; + private TextField _shareText; + private TextField _shareUrl; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + scroll.Add(MakeSectionLabel("Alerts")); + + _alertTitle = new TextField("Title") { value = "Delete Save?" }; + _alertMessage = new TextField("Message") { value = "This action cannot be undone." }; + scroll.Add(_alertTitle); + scroll.Add(_alertMessage); + + var alertRow = new VisualElement(); + alertRow.style.flexDirection = FlexDirection.Row; + alertRow.Add(MakePrimaryButton("Show Alert (modal)", () => PushAlert(isSheet: false))); + alertRow.Add(MakePrimaryButton("Show Action Sheet", () => PushAlert(isSheet: true))); + scroll.Add(alertRow); + + scroll.Add(MakeSectionLabel("Toasts")); + + _toastMessage = new TextField("Message") { value = "Item Collected!" }; + _toastLongDuration = new Toggle("Long duration") { value = false }; + scroll.Add(_toastMessage); + scroll.Add(_toastLongDuration); + + var toastBtn = MakePrimaryButton("Show Toast", () => + { + MobileSimulatorState.PushToast(new SimulatedToastSpec + { + Message = _toastMessage.value, + IsLongDuration = _toastLongDuration.value, + }); + NativeUiService.ShowToastMessage(_toastMessage.value, _toastLongDuration.value); + }); + scroll.Add(toastBtn); + + scroll.Add(MakeSectionLabel("Review")); + scroll.Add(MakePrimaryButton("Request Review", () => + { + MobileSimulatorState.PushReview(); + NativeUiService.RequestReview(); + })); + + scroll.Add(MakeSectionLabel("Share")); + + _shareText = new TextField("Text") { value = "Check out my high score!" }; + _shareUrl = new TextField("URL") { value = "https://example.com/game" }; + scroll.Add(_shareText); + scroll.Add(_shareUrl); + + scroll.Add(MakePrimaryButton("Share", () => + { + MobileSimulatorState.PushShare(new SimulatedShareSpec + { + Text = _shareText.value, + Url = _shareUrl.value, + }); + NativeUiService.Share(_shareText.value, _shareUrl.value); + })); + + var bar = MakeActionBar(); + bar.Add(MakePrimaryDangerButton("Dismiss All Mocks", () => MobileSimulatorState.PushDismissAll())); + scroll.Add(bar); + + Add(scroll); + } + + protected override void Refresh() { } + + 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 }); + } + } +} diff --git a/Editor/Explorer/Tabs/NativeUiTab.cs.meta b/Editor/Explorer/Tabs/NativeUiTab.cs.meta new file mode 100644 index 0000000..21cb81a --- /dev/null +++ b/Editor/Explorer/Tabs/NativeUiTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0db082006d78f415c8209e43397da629 \ No newline at end of file diff --git a/Editor/Explorer/Tabs/NotificationsTab.cs b/Editor/Explorer/Tabs/NotificationsTab.cs new file mode 100644 index 0000000..0d92c9b --- /dev/null +++ b/Editor/Explorer/Tabs/NotificationsTab.cs @@ -0,0 +1,156 @@ +using System; +using System.Text; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using GameLovers.MobileServices.Notifications; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Notifications tab — schedule test, list pending, surface banner mocks on the simulator. + public sealed class NotificationsTab : MobileServiceTab + { + public override string DisplayName => "Notifications"; + protected override int RefreshIntervalMs => 500; + + private MobileNotificationService _service; + private Label _statusLabel; + private VisualElement _pendingList; + private Label _channelsLabel; + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + _statusLabel = new Label(); + scroll.Add(_statusLabel); + + scroll.Add(MakeSectionLabel("Channels")); + _channelsLabel = new Label("(none — service not initialised yet)"); + _channelsLabel.AddToClassList("tab-empty-label"); + scroll.Add(_channelsLabel); + + scroll.Add(MakeSectionLabel("Schedule test")); + var scheduleRow = new VisualElement(); + scheduleRow.style.flexDirection = FlexDirection.Row; + scheduleRow.Add(MakePrimaryButton("In 1s", () => ScheduleTest(1))); + scheduleRow.Add(MakePrimaryButton("In 5s", () => ScheduleTest(5))); + scheduleRow.Add(MakePrimaryButton("In 30s", () => ScheduleTest(30))); + scroll.Add(scheduleRow); + + scroll.Add(MakeSectionLabel("Pending")); + _pendingList = new VisualElement(); + scroll.Add(_pendingList); + + var bar = MakeActionBar(); + bar.Add(MakePrimaryButton("Initialise (default channel)", InitialiseService)); + bar.Add(MakePrimaryDangerButton("Cancel All", () => + { + if (_service == null) return; + _service.CancelAllScheduledNotifications(); + Refresh(); + })); + scroll.Add(bar); + + Add(scroll); + Refresh(); + } + + protected override void Refresh() + { + if (_service == null) + { + _statusLabel.text = "Notifications service: (none — Initialise to spawn a host MonoBehaviour)"; + _pendingList.Clear(); + _pendingList.Add(MakeEmptyLabel("Service not initialised.")); + return; + } + + var channels = _service.Channels; + if (channels == null || channels.Count == 0) + { + _channelsLabel.text = "(no channels)"; + } + else + { + var sb = new StringBuilder(); + for (var i = 0; i < channels.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(channels[i].Id); + sb.Append(" / "); + sb.Append(channels[i].Name); + } + _channelsLabel.text = sb.ToString(); + } + + _statusLabel.text = $"Notifications service: mode={_service.CurrentMode}, pending={_service.PendingNotifications.Count}"; + _pendingList.Clear(); + if (_service.PendingNotifications.Count == 0) + { + _pendingList.Add(MakeEmptyLabel("No pending notifications.")); + return; + } + + foreach (var pending in _service.PendingNotifications) + { + var row = MakeRow($"{pending.Notification.Title ?? "(no title)"}", pending.Notification.DeliveryTime?.ToString("u") ?? "(no time)"); + _pendingList.Add(row); + } + } + + protected override void OnExitingPlayMode() + { + _service = null; + } + + private void InitialiseService() + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] Notifications service creates a DontDestroyOnLoad GameObject — requires Play mode."); + return; + } + if (_service != null) return; + + _service = new MobileNotificationService(new GameNotificationChannel("default", "Default", "Default notifications")); + Refresh(); + } + + private void ScheduleTest(int seconds) + { + if (!Application.isPlaying) + { + Debug.Log("[MobileServicesExplorer] Notifications scheduling requires Play mode."); + return; + } + if (_service == null) + { + InitialiseService(); + } + if (_service == null) return; + + var notification = _service.CreateNotification(); + notification.Title = $"Test in {seconds}s"; + notification.Body = $"Mock heads-up at {DateTime.Now.AddSeconds(seconds):HH:mm:ss}"; + notification.Channel = "default"; + notification.DeliveryTime = DateTime.Now.AddSeconds(seconds); + _service.ScheduleNotification(notification); + + // Heads-up banner on the simulator at the simulated delivery moment. + var deliverAtMs = seconds * 1000; + schedule.Execute(() => + { + MobileSimulatorState.PushNotificationBanner(new SimulatedNotificationBannerSpec + { + ChannelName = notification.Channel ?? "default", + Title = notification.Title, + Body = notification.Body, + }); + }).StartingIn(deliverAtMs); + } + } +} diff --git a/Editor/Explorer/Tabs/NotificationsTab.cs.meta b/Editor/Explorer/Tabs/NotificationsTab.cs.meta new file mode 100644 index 0000000..e1fe007 --- /dev/null +++ b/Editor/Explorer/Tabs/NotificationsTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd80d8b5edd35493f9dc3c0b63245466 \ No newline at end of file diff --git a/Editor/Explorer/Tabs/OverviewTab.cs b/Editor/Explorer/Tabs/OverviewTab.cs new file mode 100644 index 0000000..f4160ad --- /dev/null +++ b/Editor/Explorer/Tabs/OverviewTab.cs @@ -0,0 +1,116 @@ +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.Explorer.Windows; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Landing tab — card grid with an Open jump-link per other tab. + public sealed class OverviewTab : MobileServiceTab + { + public override string DisplayName => "Overview"; + protected override int RefreshIntervalMs => 1000; + + private readonly MobileServicesExplorerWindow _window; + private VisualElement _grid; + + public OverviewTab(MobileServicesExplorerWindow window) + { + _window = window; + } + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + _grid = new VisualElement(); + _grid.AddToClassList("overview-grid"); + scroll.Add(_grid); + + Add(scroll); + } + + protected override void Refresh() + { + _grid.Clear(); + + _grid.Add(MakeCard("Native UI", "alerts / toasts / share / review", true)); + _grid.Add(MakeCard("Haptics", "9 presets + custom + auto-stop", true)); + _grid.Add(MakeCard("Notifications", "channels + scheduling + queueing", true)); + _grid.Add(MakeCard("Gestures", "EnhancedTouch swipes + taps", true)); + _grid.Add(BuildDeviceCard()); + _grid.Add(BuildPermissionsCard()); + _grid.Add(BuildAttDeepLinkCard()); + } + + private VisualElement BuildDeviceCard() + { + var card = MakeCardBase("Device"); + var pill = new Label($"safe-area: {UnityEngine.Screen.safeArea.size}"); + pill.AddToClassList("status-ok"); + card.Add(pill); + + var actions = new VisualElement(); + actions.AddToClassList("overview-card-actions"); + actions.Add(MakeOpenButton()); + card.Add(actions); + return card; + } + + private VisualElement BuildPermissionsCard() + { + var card = MakeCardBase("Permissions"); + var pill = new Label("editor: Granted by default"); + pill.AddToClassList("status-ok"); + card.Add(pill); + var actions = new VisualElement(); + actions.AddToClassList("overview-card-actions"); + actions.Add(MakeOpenButton()); + card.Add(actions); + return card; + } + + private VisualElement BuildAttDeepLinkCard() + { + var card = MakeCardBase("ATT + Deep Links"); + var pill = new Label("editor short-circuit: Authorized"); + pill.AddToClassList("status-ok"); + card.Add(pill); + var actions = new VisualElement(); + actions.AddToClassList("overview-card-actions"); + actions.Add(MakeOpenButton()); + card.Add(actions); + return card; + } + + private VisualElement MakeCard(string title, string subtitle, bool isOk) where TTab : MobileServiceTab + { + var card = MakeCardBase(title); + var pill = new Label(subtitle); + pill.AddToClassList(isOk ? "status-ok" : "status-warn"); + card.Add(pill); + + var actions = new VisualElement(); + actions.AddToClassList("overview-card-actions"); + actions.Add(MakeOpenButton()); + card.Add(actions); + return card; + } + + private static VisualElement MakeCardBase(string title) + { + var card = new VisualElement(); + card.AddToClassList("overview-card"); + var titleLbl = new Label(title); + titleLbl.AddToClassList("overview-card-title"); + card.Add(titleLbl); + return card; + } + + private Button MakeOpenButton() where TTab : MobileServiceTab + { + return new Button(() => _window?.SelectTab()) { text = "Open" }; + } + } +} diff --git a/Editor/Explorer/Tabs/OverviewTab.cs.meta b/Editor/Explorer/Tabs/OverviewTab.cs.meta new file mode 100644 index 0000000..efb405a --- /dev/null +++ b/Editor/Explorer/Tabs/OverviewTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0477f3e9201cd4e17bc275dd8a4c8fb3 \ No newline at end of file diff --git a/Editor/Explorer/Tabs/PermissionsTab.cs b/Editor/Explorer/Tabs/PermissionsTab.cs new file mode 100644 index 0000000..b375705 --- /dev/null +++ b/Editor/Explorer/Tabs/PermissionsTab.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using GameLovers.MobileServices.Editor.Simulation; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Tabs +{ + /// Permissions tab — per-permission status pill + Check/Request + "Simulate next" dropdown. + public sealed class PermissionsTab : MobileServiceTab + { + public override string DisplayName => "Permissions"; + protected override int RefreshIntervalMs => 1000; + + private readonly PermissionsService _service = new PermissionsService(); + private readonly Dictionary _statusPills = new Dictionary(); + private readonly Dictionary _resultDropdowns = + new Dictionary(); + + protected override void BuildUi() + { + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.AddToClassList("tab-scroll"); + + scroll.Add(MakeSectionLabel("Permissions")); + + foreach (AppPermission p in Enum.GetValues(typeof(AppPermission))) + { + scroll.Add(BuildPermissionRow(p)); + } + + scroll.Add(MakeSectionLabel("Notes")); + var note = new Label( + "Editor short-circuits Check/Request to Granted by default. The 'Simulate next' dropdown lets you queue an alternative result for the next RequestAsync call. The Mobile Simulator window also paints the platform-shaped permission dialog with the configured usage description."); + note.style.whiteSpace = WhiteSpace.Normal; + note.AddToClassList("tab-empty-label"); + scroll.Add(note); + + Add(scroll); + } + + protected override void Refresh() + { + var snapshot = _service.CheckSnapshot(); + foreach (var kv in snapshot) + { + if (_statusPills.TryGetValue(kv.Key, out var pill)) + { + ApplyStatusPill(pill, kv.Value); + } + } + } + + private VisualElement BuildPermissionRow(AppPermission permission) + { + var row = new VisualElement(); + row.AddToClassList("row"); + + var lbl = new Label(permission.ToString()); + lbl.AddToClassList("row-label"); + row.Add(lbl); + + var pill = new Label(); + ApplyStatusPill(pill, _service.Check(permission)); + _statusPills[permission] = pill; + row.Add(pill); + + row.Add(MakeRowButton("Check", () => + { + var status = _service.Check(permission); + ApplyStatusPill(pill, status); + })); + + row.Add(MakeRowButton("Request", () => + { + _ = RequestAsyncDelegate(permission, pill); + })); + + var dropdown = new DropdownField(new List + { + "(no override)", + PermissionStatus.Granted.ToString(), + PermissionStatus.Denied.ToString(), + PermissionStatus.NotDetermined.ToString(), + PermissionStatus.Restricted.ToString(), + }, 0); + dropdown.RegisterValueChangedCallback(evt => + { + if (evt.newValue == "(no override)") + { + EditorPlatformSimulator.QueuePermissionResult(permission, null); + } + else if (Enum.TryParse(evt.newValue, out var parsed)) + { + EditorPlatformSimulator.QueuePermissionResult(permission, parsed); + } + }); + dropdown.style.minWidth = 130; + dropdown.style.marginLeft = 6; + _resultDropdowns[permission] = dropdown; + row.Add(dropdown); + + row.Add(MakeRowButton("Show Mock", () => + { + MobileSimulatorState.PushPermissionDialog(new SimulatedPermissionDialogSpec + { + TypeName = permission.ToString(), + UsageDescription = $"(set NSUsageDescription for {permission} in Project Settings)", + IsAtt = false, + }); + })); + + return row; + } + + private async System.Threading.Tasks.Task RequestAsyncDelegate(AppPermission permission, Label pill) + { + var result = await _service.RequestAsync(permission); + ApplyStatusPill(pill, result); + } + + private static void ApplyStatusPill(Label pill, PermissionStatus status) + { + pill.text = status.ToString(); + pill.RemoveFromClassList("perm-pill-granted"); + pill.RemoveFromClassList("perm-pill-denied"); + pill.RemoveFromClassList("perm-pill-undetermined"); + pill.RemoveFromClassList("perm-pill-restricted"); + switch (status) + { + case PermissionStatus.Granted: pill.AddToClassList("perm-pill-granted"); break; + case PermissionStatus.Denied: pill.AddToClassList("perm-pill-denied"); break; + case PermissionStatus.Restricted: pill.AddToClassList("perm-pill-restricted"); break; + default: pill.AddToClassList("perm-pill-undetermined"); break; + } + } + } +} diff --git a/Editor/Explorer/Tabs/PermissionsTab.cs.meta b/Editor/Explorer/Tabs/PermissionsTab.cs.meta new file mode 100644 index 0000000..d3ca66f --- /dev/null +++ b/Editor/Explorer/Tabs/PermissionsTab.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0da0bdc48702e4c30a0b92cb1ae59bb2 \ No newline at end of file diff --git a/Editor/Explorer/Windows.meta b/Editor/Explorer/Windows.meta new file mode 100644 index 0000000..5aed1ca --- /dev/null +++ b/Editor/Explorer/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2c153266b6d6345f98e0e7d094e4b983 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs b/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs new file mode 100644 index 0000000..264f546 --- /dev/null +++ b/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using GameLovers.MobileServices.Editor.Explorer.Tabs; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.Windows +{ + /// + /// Main Mobile Services Explorer dockable window. Open via Tools > GameLovers > + /// Mobile Services Explorer. Top-row Render as: iOS | Android toggle drives the + /// platform skin of the truth-mirror . + /// + public class MobileServicesExplorerWindow : EditorWindow + { + private const string SelectedTabPrefKey = "GameLovers.MobileServicesExplorer.SelectedTab"; + private const float MinWidth = 640f; + private const float MinHeight = 480f; + + private TabView _tabView; + private DropdownField _platformDropdown; + private readonly List _tabs = new List(); + + [MenuItem("Tools/GameLovers/Mobile Services Explorer")] + public static MobileServicesExplorerWindow Open() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Mobile Services Explorer"); + window.minSize = new Vector2(MinWidth, MinHeight); + window.Show(); + return window; + } + + /// + /// Opens the Explorer and navigates to the tab matching . + /// + public static MobileServicesExplorerWindow OpenOnTab() where T : MobileServiceTab + { + var window = Open(); + window.SelectTab(); + return window; + } + + /// Navigates to the tab matching . No-ops if not registered. + public void SelectTab() where T : MobileServiceTab + { + for (var i = 0; i < _tabs.Count; i++) + { + if (_tabs[i] is T) + { + _tabView.activeTab = _tabView[i] as Tab; + return; + } + } + } + + /// The tabs registered in this window. Exposed for tests. + internal IReadOnlyList RegisteredTabs => _tabs; + + private void CreateGUI() + { + rootVisualElement.style.flexGrow = 1; + + LoadSharedStyleSheet(); + + BuildHeader(); + + _tabView = new TabView { name = "mobile-service-tab-view" }; + _tabView.style.flexGrow = 1; + rootVisualElement.Add(_tabView); + + RegisterTabs(); + RestoreSelectedTab(); + + _tabView.activeTabChanged += OnActiveTabChanged; + MobileSimulatorState.PluginConnectedChanged += OnPluginConnectedChanged; + ApplyPluginConnectedState(MobileSimulatorState.IsActivePluginConnected); + } + + private void LoadSharedStyleSheet() + { + var guids = AssetDatabase.FindAssets("MobileServicesExplorerWindow t:StyleSheet"); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (path.EndsWith("MobileServicesExplorerWindow.uss")) + { + var sheet = AssetDatabase.LoadAssetAtPath(path); + if (sheet != null) + { + rootVisualElement.styleSheets.Add(sheet); + } + return; + } + } + } + + private void BuildHeader() + { + var headerRow = new VisualElement { name = "explorer-header" }; + headerRow.AddToClassList("explorer-header"); + + var label = new Label("Render as"); + label.AddToClassList("explorer-header-label"); + headerRow.Add(label); + + _platformDropdown = new DropdownField + { + choices = new List { SimulatedPlatform.iOS.ToString(), SimulatedPlatform.Android.ToString() }, + value = MobileSimulatorState.Platform.ToString(), + }; + _platformDropdown.AddToClassList("explorer-platform-dropdown"); + _platformDropdown.RegisterValueChangedCallback(evt => + { + if (System.Enum.TryParse(evt.newValue, out var parsed)) + { + MobileSimulatorState.Platform = parsed; + } + }); + headerRow.Add(_platformDropdown); + + var openSimBtn = new Button(() => MobileSimulatorWindow.Open()) { text = "Open Simulator" }; + openSimBtn.AddToClassList("explorer-header-button"); + headerRow.Add(openSimBtn); + + rootVisualElement.Add(headerRow); + } + + private void RegisterTabs() + { + _tabs.Clear(); + + AddTab(new OverviewTab(this)); + AddTab(new NativeUiTab()); + AddTab(new HapticsTab()); + AddTab(new NotificationsTab()); + AddTab(new GesturesTab()); + AddTab(new DeviceTab()); + AddTab(new PermissionsTab()); + AddTab(new AttDeepLinkTab()); + } + + private void AddTab(MobileServiceTab serviceTab) + { + var tab = new Tab(serviceTab.DisplayName); + tab.Add(serviceTab); + _tabView.Add(tab); + _tabs.Add(serviceTab); + } + + private void RestoreSelectedTab() + { + var savedIndex = EditorPrefs.GetInt(SelectedTabPrefKey, 0); + if (savedIndex >= 0 && savedIndex < _tabView.childCount) + { + _tabView.activeTab = _tabView[savedIndex] as Tab; + } + } + + private void OnActiveTabChanged(Tab previous, Tab current) + { + var index = _tabView.IndexOf(current); + if (index >= 0) + { + EditorPrefs.SetInt(SelectedTabPrefKey, index); + } + } + + private void OnDisable() + { + if (_tabView != null) + { + _tabView.activeTabChanged -= OnActiveTabChanged; + } + MobileSimulatorState.PluginConnectedChanged -= OnPluginConnectedChanged; + } + + private void OnPluginConnectedChanged(bool connected) => ApplyPluginConnectedState(connected); + + private void ApplyPluginConnectedState(bool connected) + { + if (_platformDropdown == null) + { + return; + } + _platformDropdown.SetEnabled(!connected); + _platformDropdown.tooltip = connected + ? "Platform is driven by the Mobile Services panel inside Unity's Device Simulator. Close the Simulator view (or disable the Mobile Services plugin in it) to regain control here." + : null; + } + } +} diff --git a/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs.meta b/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs.meta new file mode 100644 index 0000000..e93dec6 --- /dev/null +++ b/Editor/Explorer/Windows/MobileServicesExplorerWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 966abdaac21c44d46ac2a59a363e1171 \ No newline at end of file diff --git a/Editor/Explorer/Windows/MobileServicesExplorerWindow.uss b/Editor/Explorer/Windows/MobileServicesExplorerWindow.uss new file mode 100644 index 0000000..e184eba --- /dev/null +++ b/Editor/Explorer/Windows/MobileServicesExplorerWindow.uss @@ -0,0 +1,288 @@ +/* ---- Shared explorer tab styles (mirrors com.gamelovers.services). ---- */ + +.explorer-header { + flex-direction: row; + align-items: center; + padding: 6px 8px; + border-bottom-width: 1px; + border-color: rgba(255, 255, 255, 0.1); +} + +.explorer-header-label { + color: rgb(180, 190, 200); + margin-right: 8px; + -unity-font-style: bold; + font-size: 11px; +} + +.explorer-platform-dropdown { + min-width: 120px; + margin-right: 8px; +} + +.explorer-header-button { + height: 22px; + padding: 0 10px; +} + +.tab-root { + flex-direction: column; + flex-grow: 1; + padding: 6px 8px; +} + +.tab-scroll { + flex-grow: 1; +} + +.tab-banner { + background-color: rgba(200, 160, 0, 0.18); + border-color: rgba(200, 160, 0, 0.5); + border-width: 1px; + border-radius: 3px; + padding: 4px 8px; + margin-bottom: 6px; + color: rgb(230, 195, 60); + font-size: 11px; + -unity-font-style: italic; +} + +.tab-section-label { + -unity-font-style: bold; + font-size: 12px; + margin-top: 6px; + margin-bottom: 2px; + color: rgb(180, 210, 255); +} + +.tab-empty-label { + color: rgb(130, 130, 130); + font-size: 11px; + -unity-font-style: italic; + padding: 4px 0; +} + +.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: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-btn { + font-size: 10px; + height: 18px; + padding: 0 6px; + margin-left: 3px; +} + +.row-btn-danger { + background-color: rgba(200, 50, 50, 0.25); + border-color: rgba(220, 80, 80, 0.6); + border-width: 1px; + color: rgb(255, 200, 200); + -unity-font-style: bold; +} + +.row-btn-danger:hover { + background-color: rgba(220, 70, 70, 0.40); + color: rgb(255, 230, 230); +} + +.action-bar { + flex-direction: row; + flex-wrap: wrap; + padding-top: 6px; + border-top-width: 1px; + border-color: rgba(255, 255, 255, 0.1); + margin-top: 6px; +} + +.action-bar Button { + margin-right: 6px; + margin-bottom: 4px; +} + +.action-primary { + background-color: rgba(70, 140, 220, 0.25); + border-color: rgba(90, 160, 240, 0.6); + border-width: 1px; + color: rgb(180, 220, 255); + -unity-font-style: bold; + height: 24px; + padding: 0 12px; + margin-right: 6px; + margin-bottom: 4px; +} + +.action-primary:hover { + background-color: rgba(70, 140, 220, 0.4); +} + +.action-primary-danger { + background-color: rgba(200, 50, 50, 0.30); + border-color: rgba(220, 80, 80, 0.65); + border-width: 1px; + color: rgb(255, 210, 210); + -unity-font-style: bold; + height: 24px; + padding: 0 12px; + margin-right: 6px; + margin-bottom: 4px; +} + +.action-primary-danger:hover { + background-color: rgba(220, 70, 70, 0.45); + color: rgb(255, 235, 235); +} + +/* ---- Overview ---- */ + +.overview-grid { + flex-direction: row; + flex-wrap: wrap; + padding: 4px; +} + +.overview-card { + flex-direction: column; + min-width: 220px; + max-width: 280px; + margin: 4px; + padding: 8px; + border-radius: 4px; + border-width: 1px; + border-color: rgba(255, 255, 255, 0.08); + background-color: rgba(255, 255, 255, 0.03); +} + +.overview-card-title { + -unity-font-style: bold; + font-size: 12px; + margin-bottom: 4px; +} + +.overview-card-actions { + flex-direction: row; + flex-wrap: wrap; + margin-top: 6px; +} + +.overview-card-actions Button { + margin-right: 4px; + margin-bottom: 2px; + font-size: 10px; + height: 18px; + padding: 0 6px; +} + +.status-ok { + background-color: rgba(50, 180, 50, 0.25); + border-color: rgba(50, 200, 50, 0.5); + border-width: 1px; + border-radius: 8px; + padding: 1px 6px; + font-size: 10px; + color: rgb(100, 220, 100); +} + +.status-warn { + background-color: rgba(200, 130, 0, 0.25); + border-color: rgba(220, 160, 0, 0.5); + border-width: 1px; + border-radius: 8px; + padding: 1px 6px; + font-size: 10px; + color: rgb(230, 180, 60); +} + +.status-err { + background-color: rgba(200, 50, 50, 0.25); + border-color: rgba(220, 80, 80, 0.5); + border-width: 1px; + border-radius: 8px; + padding: 1px 6px; + font-size: 10px; + color: rgb(230, 100, 100); +} + +/* ---- 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/Windows/MobileServicesExplorerWindow.uss.meta b/Editor/Explorer/Windows/MobileServicesExplorerWindow.uss.meta new file mode 100644 index 0000000..bc058b1 --- /dev/null +++ b/Editor/Explorer/Windows/MobileServicesExplorerWindow.uss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 170f8dce5b8d74eaa9554f5250c53694 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Editor/GameLovers.MobileServices.Editor.asmdef b/Editor/GameLovers.MobileServices.Editor.asmdef new file mode 100644 index 0000000..8ae9d96 --- /dev/null +++ b/Editor/GameLovers.MobileServices.Editor.asmdef @@ -0,0 +1,22 @@ +{ + "name": "GameLovers.MobileServices.Editor", + "rootNamespace": "GameLovers.MobileServices.Editor", + "references": [ + "GameLovers.MobileServices", + "Unity.InputSystem", + "Unity.Notifications", + "Unity.Notifications.Android", + "Unity.Notifications.iOS" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/GameLovers.MobileServices.Editor.asmdef.meta b/Editor/GameLovers.MobileServices.Editor.asmdef.meta new file mode 100644 index 0000000..6cf13b7 --- /dev/null +++ b/Editor/GameLovers.MobileServices.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 16130ccaee8bb4c2889e6ca9c93751e2 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Settings.meta b/Editor/Settings.meta new file mode 100644 index 0000000..2d6f893 --- /dev/null +++ b/Editor/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d7d96f38101446009c574577d3d50a8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Settings/MobileServicesScanner.cs b/Editor/Settings/MobileServicesScanner.cs new file mode 100644 index 0000000..c22de09 --- /dev/null +++ b/Editor/Settings/MobileServicesScanner.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using GameLovers.MobileServices.Device; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Settings +{ + /// Outcome of . + public sealed class ProjectScanResult + { + public HashSet ReferencedPermissions { get; } = new HashSet(); + public bool UsesAtt; + public bool UsesAudioSession; + public bool UsesNotifications; + public bool UsesDeepLinks; + public bool UsesNativeUiShare; + } + + /// + /// Reflection-based scan over user assemblies that pre-fills capability toggles for the + /// Settings Provider and the build postprocessor. Pessimistic by design — false positives are + /// preferred over false negatives (a build shipping without a required entitlement). + /// + internal static class MobileServicesScanner + { + public static ProjectScanResult Scan() + { + var result = new ProjectScanResult(); + + var notificationsType = typeof(MobileServices.Notifications.MobileNotificationService); + var deepLinkType = typeof(DeepLinkService); + var audioSessionType = typeof(IosAudioSessionService); + var permissionsType = typeof(PermissionsService); + var permissionsInterface = typeof(IPermissionsService); + var attType = typeof(AttService); + var attInterface = typeof(IAttService); + var nativeUiType = typeof(MobileServices.NativeUi.NativeUiService); + + // Walk every user assembly. Built-in / package assemblies are skipped to keep the scan fast. + var assemblies = System.AppDomain.CurrentDomain.GetAssemblies(); + foreach (var assembly in assemblies) + { + if (!IsScannableAssembly(assembly)) continue; + + var refs = assembly.GetReferencedAssemblies(); + var refsRuntime = false; + foreach (var name in refs) + { + if (name.Name == "GameLovers.MobileServices") + { + refsRuntime = true; + break; + } + } + if (!refsRuntime) continue; + + Type[] types; + try { types = assembly.GetTypes(); } + catch (ReflectionTypeLoadException e) { types = e.Types ?? Array.Empty(); } + catch { continue; } + + foreach (var type in types) + { + if (type == null) continue; + if (TypeReferences(type, notificationsType)) result.UsesNotifications = true; + if (TypeReferences(type, deepLinkType)) result.UsesDeepLinks = true; + if (TypeReferences(type, audioSessionType)) result.UsesAudioSession = true; + if (TypeReferences(type, attType) || + TypeReferences(type, attInterface)) result.UsesAtt = true; + if (TypeReferences(type, permissionsType) || + TypeReferences(type, permissionsInterface)) + { + // Pessimistic: when permissions are referenced but we can't infer which ones, + // flag every permission as potentially-required so the user is prompted to + // fill in usage descriptions explicitly. They can untoggle the ones they + // don't actually call. + foreach (AppPermission p in Enum.GetValues(typeof(AppPermission))) + { + result.ReferencedPermissions.Add(p); + } + } + if (TypeReferences(type, nativeUiType)) result.UsesNativeUiShare = true; + } + } + + return result; + } + + private static bool IsScannableAssembly(Assembly assembly) + { + // Skip Unity-installed / mscorlib / NuGet-style assemblies — their names are well-known. + var name = assembly.GetName().Name; + if (string.IsNullOrEmpty(name)) return false; + if (name.StartsWith("Unity")) return false; + if (name.StartsWith("System")) return false; + if (name.StartsWith("Microsoft")) return false; + if (name.StartsWith("mscorlib")) return false; + if (name.StartsWith("netstandard")) return false; + if (name.StartsWith("nunit")) return false; + if (name.StartsWith("NUnit")) return false; + if (name.StartsWith("NSubstitute")) return false; + if (name.StartsWith("Mono.")) return false; + return true; + } + + private static bool TypeReferences(Type type, Type target) + { + try + { + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) + { + if (Match(field.FieldType, target)) return true; + } + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) + { + if (Match(prop.PropertyType, target)) return true; + } + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + if (Match(method.ReturnType, target)) return true; + foreach (var p in method.GetParameters()) + { + if (Match(p.ParameterType, target)) return true; + } + } + } + catch + { + // Reflection on a few framework types throws TypeLoadException intermittently; ignore. + } + return false; + } + + private static bool Match(Type candidate, Type target) + { + if (candidate == target) return true; + if (candidate.IsArray) return Match(candidate.GetElementType(), target); + if (candidate.IsGenericType) + { + foreach (var a in candidate.GetGenericArguments()) + { + if (Match(a, target)) return true; + } + } + return false; + } + } +} diff --git a/Editor/Settings/MobileServicesScanner.cs.meta b/Editor/Settings/MobileServicesScanner.cs.meta new file mode 100644 index 0000000..82f5bc0 --- /dev/null +++ b/Editor/Settings/MobileServicesScanner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f9feee602bd84e6b91fa28288324120 \ No newline at end of file diff --git a/Editor/Settings/MobileServicesSettings.cs b/Editor/Settings/MobileServicesSettings.cs new file mode 100644 index 0000000..11fa4ff --- /dev/null +++ b/Editor/Settings/MobileServicesSettings.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using GameLovers.MobileServices.Device; +using UnityEditor; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Settings +{ + /// + /// Per-locale text for one configured usage description (e.g. NSCameraUsageDescription). + /// + [Serializable] + public sealed class LocaleEntry + { + [SerializeField] public string LocaleCode = "en"; + [TextArea(2, 4)] + [SerializeField] public string UsageDescription; + } + + [Serializable] + public sealed class PermissionUsageRow + { + [SerializeField] public AppPermission Permission; + [SerializeField] public List Entries = new List { new LocaleEntry() }; + } + + [Serializable] + public sealed class AttUsageRow + { + [SerializeField] public List Entries = new List { new LocaleEntry() }; + } + + [Serializable] + public sealed class CapabilityToggles + { + [SerializeField] public bool PushNotifications; + [SerializeField] public bool BackgroundAudio; + [SerializeField] public bool AppTracking; + [SerializeField] public bool AssociatedDomains; + [SerializeField] public List AssociatedDomainList = new List(); + } + + [Serializable] + public sealed class AndroidManifestToggles + { + [SerializeField] public bool ReadMediaImages; + [SerializeField] public bool PostNotifications; + [SerializeField] public bool RecordAudio; + [SerializeField] public bool Camera; + [SerializeField] public bool AccessFineLocation; + [SerializeField] public bool IncludeShareQueriesBlock; + } + + /// + /// Editor-only project settings for the Mobile Services build pipeline. Persisted to + /// ProjectSettings/MobileServicesSettings.asset (project-shared — commit to VCS). + /// See docs/build-pipeline.md for the full schema and behaviour. + /// + [FilePath("ProjectSettings/MobileServicesSettings.asset", FilePathAttribute.Location.ProjectFolder)] + public sealed class MobileServicesSettings : ScriptableSingleton + { + [SerializeField] private List _permissionDescriptions = new List(); + [SerializeField] private AttUsageRow _attUsageDescription = new AttUsageRow(); + [SerializeField] private CapabilityToggles _capabilities = new CapabilityToggles(); + [SerializeField] private AndroidManifestToggles _androidManifest = new AndroidManifestToggles(); + [SerializeField] private bool _allowPlaceholderUsageDescriptions; + [SerializeField] private bool _scanPopulatedCapabilities; + [SerializeField] private bool _enableRuntimeSimulatorOverlay; + + /// Per-permission usage description rows. Reads as read-only; mutate via the explicit helpers. + public IReadOnlyList PermissionDescriptions => _permissionDescriptions; + + /// ATT (`NSUserTrackingUsageDescription`) per-locale row. + public AttUsageRow AttUsageDescription => _attUsageDescription; + + public CapabilityToggles Capabilities => _capabilities; + public AndroidManifestToggles AndroidManifest => _androidManifest; + + /// + /// CI / preview-build soft mode. When true, the iOS postprocessor injects a + /// [GameLovers placeholder] string for any missing usage description instead of failing + /// the build. Apple WILL reject a submission that ships these placeholders — by design. + /// + public bool AllowPlaceholderUsageDescriptions + { + get => _allowPlaceholderUsageDescriptions; + set + { + _allowPlaceholderUsageDescriptions = value; + Save(true); + } + } + + /// True once the user has hit "Scan project for used services" at least once. + public bool ScanPopulatedCapabilities + { + get => _scanPopulatedCapabilities; + set + { + _scanPopulatedCapabilities = value; + Save(true); + } + } + + /// + /// Opt-in: spawn the editor-only runtime simulator overlay (UIDocument inside the Game / + /// Simulator view) whenever the user enters play mode. The overlay paints the same mocks + /// the MobileSimulatorWindow renders, but pixel-aligned with the simulated device's + /// Screen.* values so what designers see matches what Apple's reviewer would see. + /// Default OFF — the overlay is opt-in to avoid spawning a DontDestroyOnLoad GameObject + /// in projects that have no use for it. + /// + public bool EnableRuntimeSimulatorOverlay + { + get => _enableRuntimeSimulatorOverlay; + set + { + _enableRuntimeSimulatorOverlay = value; + Save(true); + } + } + + /// + /// Returns the row for , creating a fresh one with a default + /// en locale entry if none exists yet. + /// + public PermissionUsageRow GetOrAddRow(AppPermission permission) + { + foreach (var row in _permissionDescriptions) + { + if (row.Permission == permission) + { + return row; + } + } + var newRow = new PermissionUsageRow { Permission = permission }; + _permissionDescriptions.Add(newRow); + Save(true); + return newRow; + } + + /// Sets the English usage description for the given permission. Convenience wrapper. + public void SetUsageDescriptionEn(AppPermission permission, string text) + { + var row = GetOrAddRow(permission); + SetLocaleEntry(row.Entries, "en", text); + Save(true); + } + + public void SetAttUsageDescriptionEn(string text) + { + SetLocaleEntry(_attUsageDescription.Entries, "en", text); + Save(true); + } + + public string GetUsageDescriptionEn(AppPermission permission) + { + foreach (var row in _permissionDescriptions) + { + if (row.Permission != permission) continue; + foreach (var entry in row.Entries) + { + if (entry.LocaleCode == "en") return entry.UsageDescription; + } + } + return null; + } + + public string GetAttUsageDescriptionEn() + { + foreach (var entry in _attUsageDescription.Entries) + { + if (entry.LocaleCode == "en") return entry.UsageDescription; + } + return null; + } + + public void Persist() => Save(true); + + private static void SetLocaleEntry(List entries, string locale, string text) + { + foreach (var entry in entries) + { + if (entry.LocaleCode == locale) + { + entry.UsageDescription = text; + return; + } + } + entries.Add(new LocaleEntry { LocaleCode = locale, UsageDescription = text }); + } + + /// + /// Returns a per-permission "Suggested copy" usage description. These follow Apple's review + /// guidelines (concrete, user-visible benefit) so the team isn't left to write the wording + /// from scratch. + /// + public static string GetSuggestedCopy(AppPermission permission) + { + switch (permission) + { + case AppPermission.Camera: + return "Allows you to take photos and videos to share inside the app."; + case AppPermission.Microphone: + return "Allows you to record audio for voice chat and clip sharing."; + case AppPermission.LocationWhenInUse: + return "Lets us show nearby content while you have the app open."; + case AppPermission.LocationAlways: + return "Lets us notify you about nearby events even when the app is in the background."; + case AppPermission.PhotoLibrary: + return "Allows you to attach photos from your library to your in-app content."; + case AppPermission.PhotoLibraryAddOnly: + return "Lets us save the screenshots and recordings you make in the app to your library."; + case AppPermission.Notifications: + return "Allows us to send you reward reminders and important game updates."; + default: + return string.Empty; + } + } + + public static string GetSuggestedAttCopy() => + "Your data will be used to provide a better personalised experience and to support our developers."; + + /// + /// Maps to the iOS Info.plist key the postprocessor must inject. + /// + public static string GetIosUsageKey(AppPermission permission) + { + switch (permission) + { + case AppPermission.Camera: return "NSCameraUsageDescription"; + case AppPermission.Microphone: return "NSMicrophoneUsageDescription"; + case AppPermission.LocationWhenInUse: return "NSLocationWhenInUseUsageDescription"; + case AppPermission.LocationAlways: return "NSLocationAlwaysAndWhenInUseUsageDescription"; + case AppPermission.PhotoLibrary: return "NSPhotoLibraryUsageDescription"; + case AppPermission.PhotoLibraryAddOnly: return "NSPhotoLibraryAddUsageDescription"; + case AppPermission.Notifications: return null; // No Info.plist key on iOS. + default: return null; + } + } + + /// + /// Returns the set of permissions that have empty English usage descriptions but ARE referenced + /// by the project (per ). The build postprocessor consults this + /// to decide whether to fail or soft-warn. + /// + public IReadOnlyList GetMissingUsageDescriptions(IEnumerable referenced) + { + var missing = new List(); + foreach (var p in referenced) + { + if (GetIosUsageKey(p) == null) + { + continue; + } + var text = GetUsageDescriptionEn(p); + if (string.IsNullOrWhiteSpace(text)) + { + missing.Add(p); + } + } + return missing; + } + + /// True when ATT capability is enabled but no English usage description is configured. + public bool IsAttUsageDescriptionMissing() + { + if (!_capabilities.AppTracking) + { + return false; + } + return string.IsNullOrWhiteSpace(GetAttUsageDescriptionEn()); + } + } +} diff --git a/Editor/Settings/MobileServicesSettings.cs.meta b/Editor/Settings/MobileServicesSettings.cs.meta new file mode 100644 index 0000000..306784e --- /dev/null +++ b/Editor/Settings/MobileServicesSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6e112b4fe6bb54e8eb0f315fec52eb1f \ No newline at end of file diff --git a/Editor/Settings/MobileServicesSettingsProvider.cs b/Editor/Settings/MobileServicesSettingsProvider.cs new file mode 100644 index 0000000..f1c5472 --- /dev/null +++ b/Editor/Settings/MobileServicesSettingsProvider.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using System.Text; +using GameLovers.MobileServices.Device; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Settings +{ + /// + /// UIToolkit-driven at Edit > Project Settings > GameLovers > + /// Mobile Services. Surfaces every field on plus a + /// project-scan button that pre-fills capability toggles based on which runtime services the + /// project actually references. + /// + internal static class MobileServicesSettingsProvider + { + private const string SettingsPath = "Project/GameLovers/Mobile Services"; + private static readonly string[] Keywords = { "Mobile", "iOS", "Android", "Info.plist", "Permission", "ATT", "Notification" }; + + // Anchored row registry so the build postprocessor's clickable error can land on the offending row. + private static readonly Dictionary _permissionRowAnchors = + new Dictionary(); + private static Label _statusBadge; + private static Label _attMissingLabel; + + [SettingsProvider] + public static SettingsProvider Create() + { + return new SettingsProvider(SettingsPath, SettingsScope.Project) + { + label = "Mobile Services", + keywords = Keywords, + activateHandler = (_, rootElement) => BuildUi(rootElement), + }; + } + + /// Opens the Project Settings panel anchored at the row for . + public static void OpenAtPermission(AppPermission permission) + { + SettingsService.OpenProjectSettings(SettingsPath); + } + + private static void BuildUi(VisualElement root) + { + _permissionRowAnchors.Clear(); + root.Clear(); + + var scroll = new ScrollView(ScrollViewMode.Vertical); + scroll.style.flexGrow = 1; + root.Add(scroll); + + BuildHeader(scroll); + + scroll.Add(MakeSectionLabel("Usage descriptions (Info.plist)")); + foreach (AppPermission permission in Enum.GetValues(typeof(AppPermission))) + { + var key = MobileServicesSettings.GetIosUsageKey(permission); + if (key == null) + { + continue; + } + scroll.Add(BuildPermissionRow(permission, key)); + } + + scroll.Add(BuildAttRow()); + + scroll.Add(MakeSectionLabel("Capabilities")); + scroll.Add(BuildCapabilityToggles()); + + scroll.Add(MakeSectionLabel("Android manifest")); + scroll.Add(BuildAndroidManifestToggles()); + + scroll.Add(MakeSectionLabel("Build behaviour")); + scroll.Add(BuildAllowPlaceholderToggle()); + + scroll.Add(MakeSectionLabel("Editor tooling")); + scroll.Add(BuildRuntimeSimulatorOverlayToggle()); + + scroll.Add(MakeSectionLabel("Tools")); + scroll.Add(BuildScanButton()); + scroll.Add(BuildPrivacyNutritionButton()); + + UpdateStatusBadge(); + } + + private static VisualElement BuildHeader(VisualElement root) + { + var headerRow = new VisualElement(); + headerRow.style.flexDirection = FlexDirection.Row; + headerRow.style.alignItems = Align.Center; + headerRow.style.marginBottom = 8; + headerRow.style.paddingTop = 6; + headerRow.style.paddingBottom = 6; + + var title = new Label("Mobile Services Settings"); + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.fontSize = 14; + title.style.flexGrow = 1; + headerRow.Add(title); + + _statusBadge = new Label(); + _statusBadge.style.paddingLeft = 8; + _statusBadge.style.paddingRight = 8; + _statusBadge.style.paddingTop = 2; + _statusBadge.style.paddingBottom = 2; + _statusBadge.style.borderTopLeftRadius = 8; + _statusBadge.style.borderTopRightRadius = 8; + _statusBadge.style.borderBottomLeftRadius = 8; + _statusBadge.style.borderBottomRightRadius = 8; + _statusBadge.style.unityFontStyleAndWeight = FontStyle.Bold; + headerRow.Add(_statusBadge); + + root.Add(headerRow); + return headerRow; + } + + private static VisualElement BuildPermissionRow(AppPermission permission, string iosKey) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Column; + row.style.paddingTop = 4; + row.style.paddingBottom = 4; + row.style.borderBottomWidth = 1; + row.style.borderBottomColor = new Color(1, 1, 1, 0.07f); + + var titleRow = new VisualElement(); + titleRow.style.flexDirection = FlexDirection.Row; + titleRow.style.alignItems = Align.Center; + + var lbl = new Label($"{permission} ({iosKey})"); + lbl.style.flexGrow = 1; + lbl.style.unityFontStyleAndWeight = FontStyle.Bold; + titleRow.Add(lbl); + + var missingPill = new Label("Missing"); + missingPill.style.paddingLeft = 6; + missingPill.style.paddingRight = 6; + missingPill.style.borderTopLeftRadius = 6; + missingPill.style.borderTopRightRadius = 6; + missingPill.style.borderBottomLeftRadius = 6; + missingPill.style.borderBottomRightRadius = 6; + missingPill.style.backgroundColor = new Color(0.78f, 0.2f, 0.2f, 0.3f); + missingPill.style.color = new Color(1f, 0.8f, 0.8f); + missingPill.style.unityFontStyleAndWeight = FontStyle.Bold; + missingPill.style.fontSize = 10; + titleRow.Add(missingPill); + + row.Add(titleRow); + + var textField = new TextField { multiline = true }; + textField.style.minHeight = 36; + textField.style.whiteSpace = WhiteSpace.Normal; + textField.value = MobileServicesSettings.instance.GetUsageDescriptionEn(permission) ?? string.Empty; + textField.RegisterValueChangedCallback(evt => + { + MobileServicesSettings.instance.SetUsageDescriptionEn(permission, evt.newValue); + UpdateMissingPill(missingPill, evt.newValue); + UpdateStatusBadge(); + }); + row.Add(textField); + + var suggestBtn = new Button(() => + { + textField.value = MobileServicesSettings.GetSuggestedCopy(permission); + }) { text = "Suggest copy" }; + suggestBtn.style.alignSelf = Align.FlexStart; + row.Add(suggestBtn); + + UpdateMissingPill(missingPill, textField.value); + _permissionRowAnchors[permission] = row; + return row; + } + + private static VisualElement BuildAttRow() + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Column; + row.style.paddingTop = 4; + row.style.paddingBottom = 4; + + var titleRow = new VisualElement(); + titleRow.style.flexDirection = FlexDirection.Row; + titleRow.style.alignItems = Align.Center; + var lbl = new Label("AppTracking (NSUserTrackingUsageDescription)"); + lbl.style.flexGrow = 1; + lbl.style.unityFontStyleAndWeight = FontStyle.Bold; + titleRow.Add(lbl); + _attMissingLabel = new Label("Missing"); + _attMissingLabel.style.paddingLeft = 6; + _attMissingLabel.style.paddingRight = 6; + _attMissingLabel.style.backgroundColor = new Color(0.78f, 0.2f, 0.2f, 0.3f); + _attMissingLabel.style.color = new Color(1f, 0.8f, 0.8f); + _attMissingLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + _attMissingLabel.style.fontSize = 10; + titleRow.Add(_attMissingLabel); + row.Add(titleRow); + + var field = new TextField { multiline = true }; + field.style.minHeight = 36; + field.style.whiteSpace = WhiteSpace.Normal; + field.value = MobileServicesSettings.instance.GetAttUsageDescriptionEn() ?? string.Empty; + field.RegisterValueChangedCallback(evt => + { + MobileServicesSettings.instance.SetAttUsageDescriptionEn(evt.newValue); + UpdateAttMissingPill(evt.newValue); + UpdateStatusBadge(); + }); + row.Add(field); + + var suggestBtn = new Button(() => { field.value = MobileServicesSettings.GetSuggestedAttCopy(); }) { text = "Suggest copy" }; + suggestBtn.style.alignSelf = Align.FlexStart; + row.Add(suggestBtn); + + UpdateAttMissingPill(field.value); + return row; + } + + private static void UpdateMissingPill(Label pill, string text) + { + pill.style.display = string.IsNullOrWhiteSpace(text) ? DisplayStyle.Flex : DisplayStyle.None; + } + + private static void UpdateAttMissingPill(string text) + { + if (_attMissingLabel == null) return; + var visible = MobileServicesSettings.instance.Capabilities.AppTracking && string.IsNullOrWhiteSpace(text); + _attMissingLabel.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; + } + + private static VisualElement BuildCapabilityToggles() + { + var c = MobileServicesSettings.instance.Capabilities; + var v = new VisualElement(); + + v.Add(MakeToggleBound("Push Notifications", c.PushNotifications, val => { c.PushNotifications = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("Background Audio (UIBackgroundModes: audio)", c.BackgroundAudio, val => { c.BackgroundAudio = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("App Tracking", c.AppTracking, val => { c.AppTracking = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("Associated Domains (deep links)", c.AssociatedDomains, val => { c.AssociatedDomains = val; PersistAndRefresh(); })); + + var domainsLabel = new Label(" Associated domain list (one per line, e.g. applinks:example.com):"); + domainsLabel.style.fontSize = 10; + v.Add(domainsLabel); + + var domainsField = new TextField { multiline = true }; + domainsField.style.minHeight = 60; + domainsField.value = string.Join("\n", c.AssociatedDomainList); + domainsField.RegisterValueChangedCallback(evt => + { + c.AssociatedDomainList = new List(); + foreach (var line in evt.newValue.Split('\n')) + { + var t = line.Trim(); + if (!string.IsNullOrEmpty(t)) c.AssociatedDomainList.Add(t); + } + PersistAndRefresh(); + }); + v.Add(domainsField); + + return v; + } + + private static VisualElement BuildAndroidManifestToggles() + { + var a = MobileServicesSettings.instance.AndroidManifest; + var v = new VisualElement(); + v.Add(MakeToggleBound("CAMERA", a.Camera, val => { a.Camera = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("RECORD_AUDIO", a.RecordAudio, val => { a.RecordAudio = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("ACCESS_FINE_LOCATION", a.AccessFineLocation, val => { a.AccessFineLocation = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("READ_MEDIA_IMAGES (API 33+)", a.ReadMediaImages, val => { a.ReadMediaImages = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("POST_NOTIFICATIONS (API 33+)", a.PostNotifications, val => { a.PostNotifications = val; PersistAndRefresh(); })); + v.Add(MakeToggleBound("Share-chooser block (API 30+)", a.IncludeShareQueriesBlock, val => { a.IncludeShareQueriesBlock = val; PersistAndRefresh(); })); + return v; + } + + private static Toggle MakeToggleBound(string text, bool initialValue, Action onChange) + { + var t = new Toggle(text) { value = initialValue }; + t.RegisterValueChangedCallback(evt => onChange(evt.newValue)); + return t; + } + + private static VisualElement BuildAllowPlaceholderToggle() + { + var note = new Label("CI / preview-build soft mode. When ON, missing usage descriptions inject \"[GameLovers placeholder]\" instead of failing the build. Apple WILL reject submissions containing the placeholder — by design."); + note.style.fontSize = 10; + note.style.whiteSpace = WhiteSpace.Normal; + note.style.unityFontStyleAndWeight = FontStyle.Italic; + note.style.marginBottom = 4; + + var toggle = new Toggle("Allow build with placeholder usage descriptions") + { + value = MobileServicesSettings.instance.AllowPlaceholderUsageDescriptions, + }; + toggle.RegisterValueChangedCallback(evt => + { + MobileServicesSettings.instance.AllowPlaceholderUsageDescriptions = evt.newValue; + UpdateStatusBadge(); + }); + + var wrapper = new VisualElement(); + wrapper.Add(note); + wrapper.Add(toggle); + return wrapper; + } + + private static VisualElement BuildRuntimeSimulatorOverlayToggle() + { + var note = new Label("When ON, entering Play mode spawns an editor-only UIDocument inside the Game / Simulator view that renders the mock native-UI surfaces at the simulated device's pixel grid. Pairs with Unity's Device Simulator (Window > General > Device Simulator). Editor-only — does NOT ship to player builds."); + note.style.fontSize = 10; + note.style.whiteSpace = WhiteSpace.Normal; + note.style.unityFontStyleAndWeight = FontStyle.Italic; + note.style.marginBottom = 4; + + var toggle = new Toggle("Enable runtime simulator overlay (play-mode)") + { + value = MobileServicesSettings.instance.EnableRuntimeSimulatorOverlay, + }; + toggle.RegisterValueChangedCallback(evt => + { + MobileServicesSettings.instance.EnableRuntimeSimulatorOverlay = evt.newValue; + }); + + var wrapper = new VisualElement(); + wrapper.Add(note); + wrapper.Add(toggle); + return wrapper; + } + + private static VisualElement BuildScanButton() + { + var btn = new Button(() => + { + var result = MobileServicesScanner.Scan(); + var c = MobileServicesSettings.instance.Capabilities; + if (result.UsesNotifications) c.PushNotifications = true; + if (result.UsesAudioSession) c.BackgroundAudio = true; + if (result.UsesAtt) c.AppTracking = true; + if (result.UsesDeepLinks) c.AssociatedDomains = true; + + var a = MobileServicesSettings.instance.AndroidManifest; + foreach (var p in result.ReferencedPermissions) + { + switch (p) + { + case AppPermission.Camera: a.Camera = true; break; + case AppPermission.Microphone: a.RecordAudio = true; break; + case AppPermission.LocationWhenInUse: + case AppPermission.LocationAlways: a.AccessFineLocation = true; break; + case AppPermission.PhotoLibrary: + case AppPermission.PhotoLibraryAddOnly: a.ReadMediaImages = true; break; + case AppPermission.Notifications: a.PostNotifications = true; break; + } + } + if (result.UsesNativeUiShare) a.IncludeShareQueriesBlock = true; + + MobileServicesSettings.instance.ScanPopulatedCapabilities = true; + PersistAndRefresh(); + Debug.Log("[Mobile Services] Project scan complete — capability toggles updated."); + }) { text = "Scan project for used services" }; + btn.style.alignSelf = Align.FlexStart; + return btn; + } + + private static VisualElement BuildPrivacyNutritionButton() + { + var note = new Label("Generates a markdown summary of the configured permissions / capabilities, formatted as a starter draft for the App Store privacy nutrition label."); + note.style.fontSize = 10; + note.style.whiteSpace = WhiteSpace.Normal; + note.style.unityFontStyleAndWeight = FontStyle.Italic; + note.style.marginBottom = 4; + + var output = new TextField { multiline = true }; + output.style.minHeight = 120; + output.value = string.Empty; + + var btn = new Button(() => { output.value = BuildPrivacyNutritionMarkdown(); }) { text = "Generate iOS Privacy Nutrition Label draft" }; + + var wrapper = new VisualElement(); + wrapper.Add(note); + wrapper.Add(btn); + wrapper.Add(output); + return wrapper; + } + + private static string BuildPrivacyNutritionMarkdown() + { + var sb = new StringBuilder(); + sb.AppendLine("# Privacy Nutrition Label (draft)"); + sb.AppendLine(); + sb.AppendLine("Generated from `ProjectSettings/MobileServicesSettings.asset`. Review and refine before App Store submission."); + sb.AppendLine(); + sb.AppendLine("## Data Used to Track You"); + if (MobileServicesSettings.instance.Capabilities.AppTracking) + { + sb.AppendLine("- Identifiers (advertising / device IDs) — App Tracking Transparency is enabled. Configure the categories per the runtime `AttService` call site."); + } + else + { + sb.AppendLine("- (none — App Tracking is disabled)"); + } + sb.AppendLine(); + sb.AppendLine("## Data Linked to You"); + foreach (AppPermission p in Enum.GetValues(typeof(AppPermission))) + { + if (MobileServicesSettings.GetIosUsageKey(p) == null) continue; + var copy = MobileServicesSettings.instance.GetUsageDescriptionEn(p); + if (string.IsNullOrWhiteSpace(copy)) continue; + sb.AppendLine($"- **{p}** — {copy}"); + } + sb.AppendLine(); + sb.AppendLine("## Data Not Collected"); + sb.AppendLine("- (review the bound services and document anything that is genuinely not collected)"); + return sb.ToString(); + } + + private static Label MakeSectionLabel(string text) + { + var lbl = new Label(text); + lbl.style.unityFontStyleAndWeight = FontStyle.Bold; + lbl.style.color = new Color(0.7f, 0.85f, 1f); + lbl.style.fontSize = 12; + lbl.style.marginTop = 12; + lbl.style.marginBottom = 2; + return lbl; + } + + private static void PersistAndRefresh() + { + MobileServicesSettings.instance.Persist(); + UpdateStatusBadge(); + } + + private static void UpdateStatusBadge() + { + if (_statusBadge == null) return; + + var missingCount = CountMissingDescriptions(); + if (missingCount == 0) + { + _statusBadge.text = "All required keys configured"; + _statusBadge.style.color = new Color(0.5f, 0.95f, 0.5f); + _statusBadge.style.backgroundColor = new Color(0.2f, 0.65f, 0.2f, 0.30f); + } + else + { + _statusBadge.text = $"{missingCount} missing key(s) — fix before iOS build"; + _statusBadge.style.color = new Color(1f, 0.85f, 0.85f); + _statusBadge.style.backgroundColor = new Color(0.78f, 0.2f, 0.2f, 0.35f); + } + } + + private static int CountMissingDescriptions() + { + var settings = MobileServicesSettings.instance; + var count = 0; + foreach (AppPermission p in Enum.GetValues(typeof(AppPermission))) + { + if (MobileServicesSettings.GetIosUsageKey(p) == null) continue; + if (string.IsNullOrWhiteSpace(settings.GetUsageDescriptionEn(p))) + { + count++; + } + } + if (settings.IsAttUsageDescriptionMissing()) + { + count++; + } + return count; + } + } +} diff --git a/Editor/Settings/MobileServicesSettingsProvider.cs.meta b/Editor/Settings/MobileServicesSettingsProvider.cs.meta new file mode 100644 index 0000000..3977f56 --- /dev/null +++ b/Editor/Settings/MobileServicesSettingsProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e17c92b3e34a426cb78f8aba528970c \ No newline at end of file diff --git a/Editor/Simulation.meta b/Editor/Simulation.meta new file mode 100644 index 0000000..3e58fee --- /dev/null +++ b/Editor/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 170a15eb4ebf04a9a812b72be2590692 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Simulation/EditorPlatformSimulator.cs b/Editor/Simulation/EditorPlatformSimulator.cs new file mode 100644 index 0000000..618bf2b --- /dev/null +++ b/Editor/Simulation/EditorPlatformSimulator.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.Explorer.Overlays; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Simulation +{ + /// + /// Editor-only façade for driving platform state (battery, connectivity, safe area, deep links, + /// permissions, ATT) from edit-mode tests and the Mobile Services Explorer. See + /// docs/explorer.md for the comparison with Unity's Device Simulator. + /// + public static class EditorPlatformSimulator + { + // ---- Device state ---- + + /// + /// Flips the simulator's low-power-mode override AND fans the change through every + /// instance you pass — mirroring what + /// NSProcessInfoPowerStateDidChangeNotification would do on a real iOS device. + /// + public static void SetIosLowPowerMode(bool enabled, params BatteryService[] services) + { + BatteryService.EditorLowPowerModeOverride = enabled; + if (services != null) + { + foreach (var s in services) + { + s?.SimulateLowPowerModeChanged(); + } + } + } + + /// + /// Pushes a safe-area override that will report on its next + /// LateUpdate diff. Each service you pass is forced to diff immediately so the + /// Explorer surfaces the change without waiting for the host's poll. + /// + public static void SetSafeArea(Rect safeArea, params SafeAreaService[] services) + { + SafeAreaService.EditorSafeAreaOverride = safeArea; + if (services != null) + { + foreach (var s in services) + { + s?.SimulateSafeAreaChanged(); + } + } + } + + /// Clears the safe-area override and restores the live Screen.safeArea read. + public static void ClearSafeAreaOverride(params SafeAreaService[] services) + { + SafeAreaService.EditorSafeAreaOverride = null; + if (services != null) + { + foreach (var s in services) + { + s?.SimulateSafeAreaChanged(); + } + } + } + + /// + /// Overrides SystemInfo.batteryLevel exposure on the next poll. The package does + /// not currently fan a battery-level event from the simulator (the runtime relies on + /// SystemInfo directly); the Explorer shows the value via the live snapshot. + /// + public static void SetBatteryLevel(float level01) + { + level01 = Mathf.Clamp01(level01); + SimulatedDeviceState.BatteryLevel = level01; + } + + /// Same caveat as . + public static void SetBatteryStatus(BatteryStatus status) + { + SimulatedDeviceState.BatteryStatus = status; + } + + /// + /// Sets the connectivity override and drives the diff on every passed + /// , firing OnStatusChanged if it transitions. + /// + public static void SetConnectivity(NetworkReachability reachability, params ConnectivityService[] services) + { + ConnectivityService.EditorReachabilityOverride = reachability; + if (services != null) + { + foreach (var s in services) + { + s?.SimulateStatusChanged(); + } + } + } + + // ---- Deep link ---- + + /// + /// Mimics the OS handing the app a runtime deep link (post-launch). Supersedes any pending + /// cold-start link and fans the URI through every OnLinkActivated subscriber. + /// + public static void SimulateDeepLink(Uri uri, params DeepLinkService[] services) + { + if (uri == null || services == null) + { + return; + } + foreach (var s in services) + { + s?.SimulateLinkActivated(uri); + } + } + + // ---- Permissions ---- + + /// + /// Queues a result that the next call to + /// will resolve to. Set to null to clear the override and + /// restore the editor default (). + /// + public static void QueuePermissionResult(AppPermission permission, PermissionStatus? status) + { + if (status == null) + { + _queuedPermissionResults.Remove(permission); + RebuildPermissionRequestOverride(); + return; + } + _queuedPermissionResults[permission] = status.Value; + RebuildPermissionRequestOverride(); + } + + /// + /// Overrides reads for a single permission. Pass + /// null to clear the override for that permission. + /// + public static void SetPermissionCheckResult(AppPermission permission, PermissionStatus? status) + { + if (status == null) + { + _checkOverrides.Remove(permission); + RebuildPermissionCheckOverride(); + return; + } + _checkOverrides[permission] = status.Value; + RebuildPermissionCheckOverride(); + } + + // ---- ATT ---- + + /// + /// Sets the result that the next will + /// resolve to and the value reads in the editor. + /// Pass null to clear both overrides. + /// + public static void QueueAttResult(AttStatus? status) + { + AttService.EditorCurrentStatusOverride = status; + AttService.EditorRequestResultOverride = status; + } + + // ---- Overlay dismissal ---- + + /// Closes any active simulator-overlay dialog without firing a button callback. + public static void DismissAllOverlays() => MobileSimulatorState.PushDismissAll(); + + // ---- Internals ---- + + private static readonly Dictionary _queuedPermissionResults = + new Dictionary(); + private static readonly Dictionary _checkOverrides = + new Dictionary(); + + private static void RebuildPermissionRequestOverride() + { + if (_queuedPermissionResults.Count == 0) + { + PermissionsService.EditorRequestOverride = null; + return; + } + + PermissionsService.EditorRequestOverride = p => + _queuedPermissionResults.TryGetValue(p, out var v) ? v : PermissionStatus.Granted; + } + + private static void RebuildPermissionCheckOverride() + { + if (_checkOverrides.Count == 0) + { + PermissionsService.EditorCheckOverride = null; + return; + } + + PermissionsService.EditorCheckOverride = p => + _checkOverrides.TryGetValue(p, out var v) ? v : PermissionStatus.Granted; + } + } + + /// + /// Static carrier for simulator-driven device snapshot values that the Explorer surfaces + /// directly (the runtime BatteryService reads SystemInfo live and cannot be + /// re-routed in the editor without a full poll re-implementation; the explorer renders this + /// override alongside the real read as the simulator hint). + /// + public static class SimulatedDeviceState + { + public static float? BatteryLevel; + public static BatteryStatus? BatteryStatus; + } +} diff --git a/Editor/Simulation/EditorPlatformSimulator.cs.meta b/Editor/Simulation/EditorPlatformSimulator.cs.meta new file mode 100644 index 0000000..1d11379 --- /dev/null +++ b/Editor/Simulation/EditorPlatformSimulator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1566c7280d49d42f095271b435488481 \ No newline at end of file diff --git a/README.md b/README.md index ceaf2c3..5666b2e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/github/v/tag/CoderGamester/com.gamelovers.mobileservices?label=version)](CHANGELOG.md) -> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Contributing](#contributing) +> **Quick Links**: [Installation](#installation) | [Quick Start](#quick-start) | [Services](#services-at-a-glance) | [Samples](#samples) | [Related docs](#related-docs) | [Contributing](#contributing) ## Why Use This Package? @@ -13,14 +13,16 @@ Building mobile-specific features in Unity often requires dealing with platform- | Problem | Solution | |---------|----------| | **Platform-specific UI code** | Native UI service bridges iOS/Android alerts, toasts, review prompts, and share sheets with one API | -| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management | +| **Notification complexity** | Notification service wraps Unity Mobile Notifications with channel management + a fluent `service.Schedule().In(...).Title(...).Send()` builder | | **Custom gesture detection** | Gesture controller provides swipe and tap detection via Unity's EnhancedTouch | | **Haptic plugin sprawl** | Zero-dependency `IHapticsService` with 9 presets, custom intensity, and time-bounded looping — built directly on iOS/Android primitives | | **Scattered device APIs** | One `IDeviceService` umbrella over `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, `DeepLink` — each child also independently mockable | +| **Deep-link routing boilerplate** | `IDeepLinkRouter.MapRoute("/promo/:id", handler)` over `IDeepLinkService` | | **iOS silent switch muting audio** | `device.AudioSession.ConfigureForPlayback()` overrides `AVAudioSession` category in one line | | **iOS App Tracking Transparency** | `device.Att.RequestAuthorizationAsync()` — direct `ATTrackingManager` bridge, no `com.unity.ads.ios-support` dependency | | **Cold-start deep link loss** | `device.DeepLink` queues the launch link for the first subscriber so you never miss it | -| **Editor testing challenges** | Editor fallbacks for all features enable testing without device builds | +| **Forgotten `Info.plist` keys → App Store rejection** | Project Settings panel + build postprocessor auto-inject `NS*UsageDescription` keys, `UIBackgroundModes`, entitlements, and Android manifest entries; fail-by-default validation lists every missing key | +| **Editor testing challenges** | Mobile Services Explorer + truth-mirror simulator paint platform-shaped mocks; `EditorPlatformSimulator` drives state for unit tests | **Built for production:** Uses Unity's official packages (`com.unity.mobile.notifications`, `com.unity.inputsystem`). Tested in real mobile games. @@ -36,7 +38,7 @@ Building mobile-specific features in Unity often requires dealing with platform- |---|---| | iOS | ✅ Fully supported | | Android | ✅ Fully supported | -| Editor | ✅ Supported (no-op fallbacks for all native services) | +| Editor | ✅ Supported (no-op fallbacks + truth-mirror simulator) | | Standalone | ⚠️ Gestures + Connectivity + SafeArea + Battery (level/status); Haptics returns `IsSupported = false`; iOS audio session / ATT are no-ops | | WebGL | ❌ Not supported | @@ -60,29 +62,6 @@ Building mobile-specific features in Unity often requires dealing with platform- --- -## Key Components - -| Component | Responsibility | -|-----------|----------------| -| **NativeUiService** | Static class bridging native iOS/Android UI (alerts, action sheets, toasts) | -| **MobileNotificationService** | Notification scheduling, cancellation, and channel management | -| **IGameNotification** | Platform-agnostic notification interface | -| **GestureController** | MonoBehaviour detecting swipe and tap gestures via EnhancedTouch | -| **SwipeInput** | Data structure with swipe direction, velocity, and consistency metrics | -| **TapInput** | Data structure for tap position and finger data | -| **IIosAudioSessionService** | Overrides the iOS silent switch so audio keeps playing (no-op elsewhere) | -| **IHapticsService** | Cross-platform haptic feedback with 9 presets, custom intensity, time-bounded looping. Zero third-party deps. | -| **IDeviceService** | Umbrella facade exposing `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, `DeepLink` | -| **IPermissionsService** | Unified iOS+Android runtime permissions (Camera, Mic, Location, Photos, Notifications) — Task-based async | -| **IAttService** | iOS App Tracking Transparency. Built directly on `ATTrackingManager` — no `com.unity.ads.ios-support` dep | -| **IDeepLinkService** | `Application.deepLinkActivated` wrapper with cold-start link queueing | -| **ISafeAreaService** | `Screen.safeArea` with change events; pairs with `SafeAreaContainer` UI Toolkit element | -| **IBatteryService** | Battery level/status + iOS/Android low-power-mode awareness with events | -| **IConnectivityService** | `Application.internetReachability` with change events | -| **IScreenWakeService** | `KeepAwake` toggle over `Screen.sleepTimeout` | - ---- - ## Quick Start ### Native UI @@ -95,15 +74,10 @@ NativeUiService.ShowAlertPopUp( title: "Delete Save?", message: "This action cannot be undone.", new AlertButton { Text = "Cancel", Style = AlertButtonStyle.Cancel }, - new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, Callback = OnDeleteConfirmed } -); + new AlertButton { Text = "Delete", Style = AlertButtonStyle.Destructive, Callback = OnDeleteConfirmed }); NativeUiService.ShowToastMessage("Item Collected!", isLongDuration: false); - -// OS-mediated rating prompt (no-op in Editor; iOS SKStoreReviewController + Android Play In-App Review). NativeUiService.RequestReview(); - -// OS share sheet. Pass any combination of text/url/imagePath; nulls are skipped. NativeUiService.Share(text: "Check out my high score!", url: "https://example.com/game"); ``` @@ -114,63 +88,40 @@ using GameLovers.MobileServices.Notifications; var service = new MobileNotificationService( new GameNotificationChannel("default", "Default", "Default notifications"), - new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders") -); - -var notification = service.CreateNotification(); -notification.Title = "Daily Reward Ready!"; -notification.Body = "Your daily reward is waiting for you!"; -notification.DeliveryTime = DateTime.Now.AddHours(24); -notification.Channel = "rewards"; -service.ScheduleNotification(notification); -``` - -### iOS Audio Session - -```csharp -using GameLovers.MobileServices.Device; - -var audio = new IosAudioSessionService(); -audio.ConfigureForPlayback(); // Call once at startup. No-op on Android / Editor. + new GameNotificationChannel("rewards", "Rewards", "Daily reward reminders")); + +service.Schedule() + .In(TimeSpan.FromHours(24)) + .Title("Daily Reward Ready!") + .Body("Your daily reward is waiting for you!") + .Channel("rewards") + .BadgeIncrement() + .Send(); ``` -### Device Services (umbrella) +### Device ```csharp using GameLovers.MobileServices.Device; IDeviceService device = new DeviceService(); -// Battery + low-power mode. -device.Battery.OnLowPowerModeChanged += () => - Debug.Log($"LPM changed -> {device.Battery.IsLowPowerMode}"); - -// Connectivity events. -device.Connectivity.OnStatusChanged += status => - Debug.Log($"Reachability changed -> {status}"); - -// Safe area for UI Toolkit. -var safeAreaContainer = new SafeAreaContainer(device.SafeArea); -rootVisualElement.Add(safeAreaContainer); - -// Keep the screen awake during gameplay. +device.Battery.OnLowPowerModeChanged += () => Debug.Log($"LPM -> {device.Battery.IsLowPowerMode}"); +device.Connectivity.OnStatusChanged += s => Debug.Log($"Reachability -> {s}"); device.ScreenWake.KeepAwake = true; - -// Override iOS silent switch. device.AudioSession.ConfigureForPlayback(); -// Runtime permissions (Task-based; no UniTask dependency). -var camera = await device.Permissions.RequestAsync(AppPermission.Camera); -if (camera == PermissionStatus.Granted) { /* … */ } +var perms = await device.Permissions.RequestAsync(AppPermission.Camera, AppPermission.Microphone); +if (perms[AppPermission.Camera] == PermissionStatus.Granted) { /* … */ } -// App Tracking Transparency (iOS 14.5+; returns Authorized on Android/Editor). var att = await device.Att.RequestAuthorizationAsync(); -// Deep links — cold-start safe; subscribe whenever, never miss a launch link. device.DeepLink.OnLinkActivated += uri => Debug.Log($"Deep link: {uri}"); -``` -Each child interface is also independently registerable for tests, so you can mock `IBatteryService` directly without going through the facade. +// Or with the router: +var router = new DeepLinkRouter(device.DeepLink); +router.MapRoute("/promo/:id", (uri, p) => OpenPromo(p["id"])); +``` ### Haptics @@ -178,114 +129,68 @@ Each child interface is also independently registerable for tests, so you can mo using GameLovers.MobileServices.Haptics; IHapticsService haptics = new HapticsService(); - -// Natural one-shot for the preset's built-in duration. haptics.PlayPreset(HapticPreset.Success); - -// Loop indefinitely until you call StopCurrentHaptic(). -haptics.PlayPresetDuration(HapticPreset.ImpactMedium, duration: -1f); -// ... later ... -haptics.StopCurrentHaptic(); - -// Loop and auto-stop after 0.5 seconds. -haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, duration: 0.5f); - -// Custom intensity (0..1) with explicit duration in milliseconds. +haptics.PlayPresetDuration(HapticPreset.ImpactHeavy, duration: 0.5f); // auto-stop after 0.5s haptics.PlayCustom(intensity01: 0.7f, durationMs: 250f); - -// Master toggle. Setting Enabled=false also stops any active haptic. -haptics.Enabled = false; +haptics.StopCurrentHaptic(); ``` -### Gesture Detection +### Umbrella facade ```csharp -using GameLovers.MobileServices.Gestures; - -// Attach GestureController MonoBehaviour to a scene GameObject -// Note: uses Unity's EnhancedTouch API; in Editor add a TouchSimulation component for mouse input +using GameLovers.MobileServices; -_gestureController.Swiped += swipe => -{ - // swipe.SwipeDirection — Up / Down / Left / Right - // swipe.SwipeVelocity — speed of the swipe - // swipe.SwipeSameness — direction consistency 0–1 (higher = cleaner) - if (swipe.SwipeSameness > 0.8f) - ProcessSwipe(swipe.SwipeDirection); -}; - -_gestureController.Tapped += tap => -{ - // tap.Position — screen position of the tap - Debug.Log($"Tapped at {tap.Position}"); -}; +IMobileService mobile = new MobileService(); // bind once +mobile.NativeUi.ShowToastMessage("hi", false); +mobile.Notifications.Schedule().In(TimeSpan.FromHours(1)).Title("x").Send(); +mobile.Haptics.PlayPreset(HapticPreset.Selection); +var camera = await mobile.Device.Permissions.RequestAsync(AppPermission.Camera); ``` --- ## Services at a Glance -### Native UI - -All methods are **static** — no initialization needed. The service is platform-gated: no-op in the Editor (logs only), throws on unsupported platforms. +| Service | Purpose | +|---------|---------| +| `NativeUiService` (static) + `INativeUiService` (instance) | Alerts, sheets, toasts, review, share | +| `INotificationService` / `MobileNotificationService` | Local + remote notifications with channel CRUD, fluent `Schedule()` builder, 4 `OperatingMode`s | +| `GestureController` | EnhancedTouch swipe + tap detection | +| `IHapticsService` / `HapticsService` | 9 cross-platform presets + custom intensity + time-bounded looping | +| `IDeviceService` / `DeviceService` | Umbrella over `SafeArea`, `ScreenWake`, `Battery`, `Connectivity`, `AudioSession`, `Permissions`, `Att`, `DeepLink` | +| `IDeepLinkRouter` / `DeepLinkRouter` | Path-pattern routing over `IDeepLinkService` | +| `IMobileService` / `MobileService` | Package-wide umbrella facade (NativeUi / Notifications / Haptics / Device) | +| `SafeAreaContainer` | UI Toolkit `VisualElement` that pads itself to the safe area | -| Method | Platform | -|--------|----------| -| `ShowAlertPopUp(isAlertSheet, title, message, buttons…)` | iOS + Android | -| `ShowToastMessage(message, isLongDuration)` | iOS + Android | -| `RequestReview()` | iOS (`SKStoreReviewController`) + Android (Play In-App Review) | -| `Share(text, url, imagePath, title)` | iOS (`UIActivityViewController`) + Android (`Intent.ACTION_SEND`) | +For full per-subsystem API reference, see [`docs/`](docs/README.md). -**Alert Button Styles:** `Default`, `Cancel`, `Destructive` - -> **Android `RequestReview()`** requires the Play Core Review library. Add to `mainTemplate.gradle`: -> `implementation 'com.google.android.play:review:2.0.1'` - -### Notification Service - -```csharp -service.CancelNotification(pending.Id); -service.CancelAllScheduledNotifications(); -var scheduled = service.PendingNotifications; -``` +--- -Key points: -- Android requires at least one channel; the first passed becomes the default. -- Creates a `DontDestroyOnLoad` host GameObject — teardown explicitly in tests or game reset flows. -- `OperatingMode.Queue*` defers scheduling to the OS until the app backgrounds. +## Editor tooling -### Gesture Controller +Two editor windows ship with the package: -Key points: -- Powered by Unity's `EnhancedTouch` API — `EnhancedTouchSupport` is enabled/disabled automatically in `OnEnable`/`OnDisable`. -- For mouse input in Editor: add a `TouchSimulation` component. -- If `minSwipeDistance <= maxTapDrift`, an interaction may qualify as both tap and swipe — tune thresholds carefully. +- **`Tools > GameLovers > Mobile Services Explorer`** — 8-tab dockable window with live status, simulator hooks, and a per-preset haptic envelope graph. +- **`Tools > GameLovers > Mobile Services Simulator Window`** — truth-mirror that paints platform-shaped mocks of every native UI surface (iOS / Android, swap via the Explorer's `Render as` toggle). -**SwipeInput fields:** +Plus a Project Settings panel at **`Edit > Project Settings > GameLovers > Mobile Services`** for per-permission usage descriptions, capability toggles, and the auto-injection build postprocessor. -| Field | Type | Description | -|---|---|---| -| `SwipeDirection` | `SwipeDirection` | Up / Down / Left / Right | -| `SwipeVelocity` | `float` | Speed of the gesture | -| `SwipeSameness` | `float` | Direction consistency 0–1 | -| `StartPosition` | `Vector2` | Screen start position | -| `EndPosition` | `Vector2` | Screen end position | +See [`docs/explorer.md`](docs/explorer.md) and [`docs/build-pipeline.md`](docs/build-pipeline.md) for the full guide. --- -## Platform-Specific Notes +## Samples -**iOS:** Native UI via Objective-C bridge (`Plugins/iOS/NativeUi.m`). Alert callbacks matched by button text — keep button texts unique per alert. +Four code-only samples ship with the package — import via `Window > Package Manager > GameLovers.MobileServices > Samples`: -**Android:** Native UI via `AndroidJavaClass` reflection. Notifications require channels (Android 8.0+). +| Sample | Purpose | +|--------|---------| +| Mobile Services Playground | Kitchen-sink wiring proof for every subsystem. | +| Haptics Palette | Designer iteration tool with sequence recorder + replay. | +| Notifications Scheduler | Channel CRUD + `OperatingMode` lifecycle demo. | +| Deep Link Router | `MapRoute` pattern + cold-start replay instructions. | -**Editor:** Alerts and toasts log to console. Notifications are logged but not scheduled. Gestures work via `TouchSimulation`. - ---- - -## Contributing - -Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). +See [`docs/samples.md`](docs/samples.md) for setup details. --- @@ -293,9 +198,23 @@ Contributions are welcome! Report bugs or request features via [GitHub Issues](h | Document | Purpose | |---|---| +| [docs/README.md](docs/README.md) | Full API reference index | +| [docs/native-ui.md](docs/native-ui.md) | Native UI deep dive | +| [docs/notifications.md](docs/notifications.md) | Notifications deep dive (channels, modes, builder, persistence) | +| [docs/haptics.md](docs/haptics.md) | Haptics deep dive (presets, envelope, looping, backends) | +| [docs/gestures.md](docs/gestures.md) | Gesture detection deep dive | +| [docs/device.md](docs/device.md) | Device umbrella + 8 children + DeepLinkRouter | +| [docs/explorer.md](docs/explorer.md) | Mobile Services Explorer & Truth-Mirror Simulator | +| [docs/build-pipeline.md](docs/build-pipeline.md) | Project Settings + build postprocessor (and manual fallback) | +| [docs/samples.md](docs/samples.md) | Samples index | +| [docs/troubleshooting.md](docs/troubleshooting.md) | Symptom-to-fix table | | [AGENTS.md](AGENTS.md) | Contributor/agent guide (architecture, gotchas, workflows) | | [CHANGELOG.md](CHANGELOG.md) | Version history | +## Contributing + +Contributions are welcome! Report bugs or request features via [GitHub Issues](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues). Include target platform (iOS/Android) and device info. For development setup, architecture, and coding standards, see [AGENTS.md](AGENTS.md). + ## Support - **Issues**: [Report bugs or request features](https://github.com/CoderGamester/com.gamelovers.mobileservices/issues) diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs index f4901cf..f8456bb 100644 --- a/Runtime/AssemblyInfo.cs +++ b/Runtime/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("GameLovers.MobileServices.EditMode.Tests")] [assembly: InternalsVisibleTo("GameLovers.MobileServices.PlayMode.Tests")] +[assembly: InternalsVisibleTo("GameLovers.MobileServices.Editor")] diff --git a/Runtime/Device/DeepLinks/DeepLinkRouter.cs b/Runtime/Device/DeepLinks/DeepLinkRouter.cs new file mode 100644 index 0000000..953f840 --- /dev/null +++ b/Runtime/Device/DeepLinks/DeepLinkRouter.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class DeepLinkRouter : IDeepLinkRouter, IDisposable + { + private readonly IDeepLinkService _deepLink; + private readonly List _routes = new List(); + + public DeepLinkRouter(IDeepLinkService deepLink) + { + _deepLink = deepLink ?? throw new ArgumentNullException(nameof(deepLink)); + _deepLink.OnLinkActivated += OnLinkActivated; + } + + public void Dispose() + { + _deepLink.OnLinkActivated -= OnLinkActivated; + _routes.Clear(); + } + + /// + public void MapRoute(string pathPattern, Action> handler) + { + if (string.IsNullOrEmpty(pathPattern)) throw new ArgumentNullException(nameof(pathPattern)); + if (handler == null) throw new ArgumentNullException(nameof(handler)); + _routes.Add(new Route(pathPattern, handler)); + } + + /// + public void RemoveRoute(string pathPattern) + { + if (string.IsNullOrEmpty(pathPattern)) return; + for (var i = _routes.Count - 1; i >= 0; i--) + { + if (_routes[i].Pattern == pathPattern) + { + _routes.RemoveAt(i); + } + } + } + + /// + public bool TryDispatch(Uri uri) + { + if (uri == null) return false; + foreach (var route in _routes) + { + if (route.TryMatch(uri, out var captured)) + { + route.Handler(uri, captured); + return true; + } + } + return false; + } + + private void OnLinkActivated(Uri uri) => TryDispatch(uri); + + private sealed class Route + { + public readonly string Pattern; + public readonly Action> Handler; + private readonly string[] _segments; + + public Route(string pattern, Action> handler) + { + Pattern = pattern; + Handler = handler; + _segments = SplitPath(pattern); + } + + public bool TryMatch(Uri uri, out IReadOnlyDictionary captured) + { + // Build the URI path segments. Treat host as the first segment so myapp://promo/123 + // matches /promo/:id (most app schemes carry the "type" in host, the "id" in path). + var segments = SplitUri(uri); + + if (segments.Length != _segments.Length) + { + captured = null; + return false; + } + + var dict = new Dictionary(); + for (var i = 0; i < _segments.Length; i++) + { + var pat = _segments[i]; + if (pat.StartsWith(":", StringComparison.Ordinal)) + { + dict[pat.Substring(1)] = segments[i]; + } + else if (!string.Equals(pat, segments[i], StringComparison.OrdinalIgnoreCase)) + { + captured = null; + return false; + } + } + captured = dict; + return true; + } + + private static string[] SplitPath(string pattern) + { + return pattern.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + } + + private static string[] SplitUri(Uri uri) + { + var host = uri.Host ?? string.Empty; + var path = uri.AbsolutePath?.Trim('/') ?? string.Empty; + var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (string.IsNullOrEmpty(host)) + { + return parts; + } + + var combined = new string[parts.Length + 1]; + combined[0] = host; + for (var i = 0; i < parts.Length; i++) + { + combined[i + 1] = parts[i]; + } + return combined; + } + } + } +} diff --git a/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta b/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta new file mode 100644 index 0000000..1ce7717 --- /dev/null +++ b/Runtime/Device/DeepLinks/DeepLinkRouter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 87b2e595f2a2a4f2e98ee67d843d2df6 \ No newline at end of file diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs b/Runtime/Device/DeepLinks/DeepLinkService.cs index 384479d..ebdfbfa 100644 --- a/Runtime/Device/DeepLinks/DeepLinkService.cs +++ b/Runtime/Device/DeepLinks/DeepLinkService.cs @@ -62,6 +62,23 @@ private void OnDeepLinkActivated(string url) _onLinkActivated?.Invoke(parsed); } +#if UNITY_EDITOR + /// + /// Editor-only simulator hook. Mimics what Application.deepLinkActivated would do + /// when the OS hands the app a link at runtime — supersedes any pending cold-start link + /// and dispatches to all current subscribers. + /// + internal void SimulateLinkActivated(Uri uri) + { + if (uri == null) + { + return; + } + _pendingColdStartLink = null; + _onLinkActivated?.Invoke(uri); + } +#endif + private static Uri TryParse(string url) { if (string.IsNullOrEmpty(url)) diff --git a/Runtime/Device/DeepLinks/IDeepLinkRouter.cs b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs new file mode 100644 index 0000000..850bc88 --- /dev/null +++ b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Routing layer over . Maps URI path patterns to handlers and + /// dispatches each incoming deep link to the first matching route. + /// + /// + /// Path-pattern syntax is intentionally minimal — the goal is to remove the per-consumer + /// switch-on-segments boilerplate, not to grow into a full URL-routing DSL: + /// + /// Literal segments match exactly. /settings matches myapp://settings. + /// Segments prefixed with : capture into the params dict. /promo/:id matches + /// myapp://promo/abc123 yielding { "id": "abc123" }. + /// Routes are checked in registration order; the first match wins. + /// + /// The router subscribes once to at + /// construction; consumers should hold the router instance for the lifetime of the app to avoid + /// re-subscription churn. + /// + public interface IDeepLinkRouter + { + /// + /// Registers a route. Path-pattern syntax: literal segments match exactly, segments prefixed + /// with : capture into the handler's params argument. + /// + void MapRoute(string pathPattern, Action> handler); + + /// Removes the route previously registered with . No-op if absent. + void RemoveRoute(string pathPattern); + + /// + /// Attempts to dispatch through the registered routes. Returns + /// true when a route matched and its handler was invoked. + /// + bool TryDispatch(Uri uri); + } +} diff --git a/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta new file mode 100644 index 0000000..03e270c --- /dev/null +++ b/Runtime/Device/DeepLinks/IDeepLinkRouter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 989c6742551db450f972afbffc51c88e \ No newline at end of file diff --git a/Runtime/Device/Permissions/IPermissionsService.cs b/Runtime/Device/Permissions/IPermissionsService.cs index e97a6aa..4cc640c 100644 --- a/Runtime/Device/Permissions/IPermissionsService.cs +++ b/Runtime/Device/Permissions/IPermissionsService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; // ReSharper disable once CheckNamespace @@ -39,5 +40,25 @@ public interface IPermissionsService /// Requests the permission, prompting the user if not yet determined. Idempotent if already granted/denied. Task RequestAsync(AppPermission permission); + + /// + /// Convenience for the common multi-permission flows (e.g. camera+mic for video chat). Awaits + /// each sequentially — iOS prompts cannot stack — and + /// returns a dictionary keyed by permission. + /// + /// + /// Default interface method — implementations may override, but the default behaviour is + /// expected to be sufficient for the vast majority of consumers. + /// + async Task> RequestAsync(params AppPermission[] permissions) + { + var result = new Dictionary(); + if (permissions == null) return result; + foreach (var p in permissions) + { + result[p] = await RequestAsync(p); + } + return result; + } } } diff --git a/Runtime/Device/Permissions/PermissionsService.cs b/Runtime/Device/Permissions/PermissionsService.cs index 709cc0a..cea3eeb 100644 --- a/Runtime/Device/Permissions/PermissionsService.cs +++ b/Runtime/Device/Permissions/PermissionsService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading.Tasks; using GameLovers.MobileServices.Device.Internal; @@ -19,11 +20,20 @@ public sealed class PermissionsService : IPermissionsService [DllImport("__Internal")] private static extern void _GameLoversPermissionsRequest(int permissionId, int requestId, string callbackGameObject, string callbackMethod); #endif +#if UNITY_EDITOR + // Editor-only override hooks consumed by EditorPlatformSimulator. When set, the editor + // short-circuit paths consult these instead of returning the default Granted. Keeps + // runtime non-Editor builds untouched. + internal static Func EditorCheckOverride; + internal static Func EditorRequestOverride; +#endif + /// public PermissionStatus Check(AppPermission permission) { #if UNITY_EDITOR - return PermissionStatus.Granted; + var over = EditorCheckOverride; + return over != null ? over(permission) : PermissionStatus.Granted; #elif UNITY_IOS return (PermissionStatus)_GameLoversPermissionsCheck((int)permission); #elif UNITY_ANDROID @@ -33,11 +43,28 @@ public PermissionStatus Check(AppPermission permission) #endif } + /// + /// Snapshot of across every value. Used by + /// the Mobile Services Explorer Permissions tab to render the per-permission status grid + /// without forcing callers to iterate the enum themselves. + /// + /// Editor introspection accessor — not part of the public surface. + internal IReadOnlyDictionary CheckSnapshot() + { + var dict = new Dictionary(); + foreach (AppPermission p in Enum.GetValues(typeof(AppPermission))) + { + dict[p] = Check(p); + } + return dict; + } + /// public Task RequestAsync(AppPermission permission) { #if UNITY_EDITOR - return Task.FromResult(PermissionStatus.Granted); + var over = EditorRequestOverride; + return Task.FromResult(over != null ? over(permission) : PermissionStatus.Granted); #elif UNITY_IOS var tcs = new TaskCompletionSource(); var id = PermissionsCallbackReceiver.Instance.Register(tcs); diff --git a/Runtime/Device/State/BatteryService.cs b/Runtime/Device/State/BatteryService.cs index be52872..74b79b3 100644 --- a/Runtime/Device/State/BatteryService.cs +++ b/Runtime/Device/State/BatteryService.cs @@ -141,9 +141,29 @@ private static bool QueryLowPowerMode() { return false; } +#elif UNITY_EDITOR + return EditorLowPowerModeOverride; #else return false; #endif } + +#if UNITY_EDITOR + // Editor-only simulator hook. EditorPlatformSimulator flips this then calls + // SimulateLowPowerModeChanged() to fan the change through to subscribers, mirroring + // what NSProcessInfoPowerStateDidChangeNotification would do on iOS. + internal static bool EditorLowPowerModeOverride; + + /// + /// Editor-only test/simulator hook. Runs the LPM refresh path that would normally be + /// driven by the iOS bridge (UnitySendMessage("DeviceServicesHost", "OnIosLowPowerModeChanged", "")), + /// re-reading and firing + /// on transition. + /// + internal void SimulateLowPowerModeChanged() + { + RefreshLowPowerMode(); + } +#endif } } diff --git a/Runtime/Device/State/ConnectivityService.cs b/Runtime/Device/State/ConnectivityService.cs index e2246cc..7b9aa22 100644 --- a/Runtime/Device/State/ConnectivityService.cs +++ b/Runtime/Device/State/ConnectivityService.cs @@ -12,8 +12,20 @@ public sealed class ConnectivityService : IConnectivityService, IDisposable private NetworkReachability _lastStatus; +#if UNITY_EDITOR + // Editor-only simulator override. When set, Status reads this instead of the live + // Application.internetReachability so the Mobile Services Explorer's Device tab can + // preview connectivity transitions without needing a real network state change. + internal static NetworkReachability? EditorReachabilityOverride; +#endif + /// - public NetworkReachability Status => Application.internetReachability; + public NetworkReachability Status => +#if UNITY_EDITOR + EditorReachabilityOverride ?? Application.internetReachability; +#else + Application.internetReachability; +#endif /// public event Action OnStatusChanged; @@ -58,5 +70,17 @@ private void Tick() _lastStatus = current; OnStatusChanged?.Invoke(current); } + +#if UNITY_EDITOR + /// + /// Editor-only simulator hook. Runs the diff path immediately so the Explorer's + /// "Set Connectivity" button surfaces transitions without waiting for the host's + /// per-second tick. + /// + internal void SimulateStatusChanged() + { + Tick(); + } +#endif } } diff --git a/Runtime/Device/State/SafeAreaService.cs b/Runtime/Device/State/SafeAreaService.cs index 9e9a753..814f2ec 100644 --- a/Runtime/Device/State/SafeAreaService.cs +++ b/Runtime/Device/State/SafeAreaService.cs @@ -13,6 +13,12 @@ public sealed class SafeAreaService : ISafeAreaService, IDisposable private Rect _lastSafeArea; private Vector2Int _lastResolution; +#if UNITY_EDITOR + // Editor-only simulator override. When set, the LateUpdate poll reports this rect + // instead of Screen.safeArea so the Explorer can drive notch/dynamic-island previews. + internal static Rect? EditorSafeAreaOverride; +#endif + /// public Rect SafeArea => _lastSafeArea; @@ -42,7 +48,11 @@ public void Dispose() private void Tick() { +#if UNITY_EDITOR + var current = EditorSafeAreaOverride ?? Screen.safeArea; +#else var current = Screen.safeArea; +#endif var resolution = new Vector2Int(Screen.width, Screen.height); if (current == _lastSafeArea && resolution == _lastResolution) @@ -54,5 +64,17 @@ private void Tick() _lastResolution = resolution; OnSafeAreaChanged?.Invoke(current); } + +#if UNITY_EDITOR + /// + /// Editor-only simulator hook. Forces an immediate diff against + /// so the Explorer's "Set Safe Area" affordance + /// surfaces the change without waiting for the next LateUpdate tick. + /// + internal void SimulateSafeAreaChanged() + { + Tick(); + } +#endif } } diff --git a/Runtime/Device/Tracking/AttService.cs b/Runtime/Device/Tracking/AttService.cs index bccc3cc..d2a2f83 100644 --- a/Runtime/Device/Tracking/AttService.cs +++ b/Runtime/Device/Tracking/AttService.cs @@ -16,6 +16,13 @@ public sealed class AttService : IAttService [DllImport("__Internal")] private static extern void _GameLoversAttRequestAuthorization(int requestId, string callbackGameObject, string callbackMethod); #endif +#if UNITY_EDITOR + // Editor-only override hooks consumed by EditorPlatformSimulator. When set, the editor + // short-circuit paths consult these instead of returning the default Authorized. + internal static AttStatus? EditorCurrentStatusOverride; + internal static AttStatus? EditorRequestResultOverride; +#endif + /// public AttStatus CurrentStatus { @@ -23,6 +30,8 @@ public AttStatus CurrentStatus { #if UNITY_IOS && !UNITY_EDITOR return (AttStatus)_GameLoversAttCurrentStatus(); +#elif UNITY_EDITOR + return EditorCurrentStatusOverride ?? AttStatus.Authorized; #else return AttStatus.Authorized; #endif @@ -37,6 +46,8 @@ public Task RequestAuthorizationAsync() var id = AttCallbackReceiver.Instance.Register(tcs); _GameLoversAttRequestAuthorization(id, "AttCallbackReceiver", "OnAttResult"); return tcs.Task; +#elif UNITY_EDITOR + return Task.FromResult(EditorRequestResultOverride ?? AttStatus.Authorized); #else return Task.FromResult(AttStatus.Authorized); #endif diff --git a/Runtime/Haptics/HapticsService.cs b/Runtime/Haptics/HapticsService.cs index d7bbbeb..9f509f6 100644 --- a/Runtime/Haptics/HapticsService.cs +++ b/Runtime/Haptics/HapticsService.cs @@ -15,6 +15,8 @@ public sealed class HapticsService : IHapticsService private bool _enabled = true; private bool _isPlaying; + private HapticPreset _currentPreset; + private float _currentDurationSeconds; public HapticsService() : this(CreateDefaultBackend()) { } @@ -23,6 +25,26 @@ internal HapticsService(IHapticsBackend backend) _backend = backend; } + /// + /// The preset most recently passed to a Play* call. Reads + /// when no haptic is currently playing or when the last call was . + /// + /// Editor introspection accessor — not part of the public surface. + internal HapticPreset CurrentPreset => _isPlaying ? _currentPreset : HapticPreset.None; + + /// + /// Real-time-seconds duration scheduled for the active haptic. 0 when nothing is playing, + /// the preset's natural one-shot duration when invoked via , + /// -1 for an indefinite loop, or the explicit positive duration passed to + /// / . + /// + /// Editor introspection accessor — not part of the public surface. + internal float CurrentDurationSeconds => _isPlaying ? _currentDurationSeconds : 0f; + + /// The backend selected for the current platform. + /// Editor introspection accessor — not part of the public surface. + internal IHapticsBackend Backend => _backend; + /// public bool Enabled { @@ -67,11 +89,15 @@ public void PlayPresetDuration(HapticPreset preset, float duration = -1f) { _backend.PlayPresetOneShot(preset); _isPlaying = true; + _currentPreset = preset; + _currentDurationSeconds = HapticEnvelopes.GetNaturalDurationSeconds(preset); return; } _backend.PlayPresetLoop(preset); _isPlaying = true; + _currentPreset = preset; + _currentDurationSeconds = duration; if (duration > 0f) { @@ -92,6 +118,8 @@ public void PlayCustom(float intensity01, float durationMs) intensity01 = Mathf.Clamp01(intensity01); _backend.PlayCustom(intensity01, durationMs); _isPlaying = true; + _currentPreset = HapticPreset.None; + _currentDurationSeconds = durationMs / 1000f; EnsureHost().ScheduleStop(durationMs / 1000f, OnAutoStop); } @@ -106,6 +134,8 @@ public void StopCurrentHaptic() } _backend.Stop(); _isPlaying = false; + _currentPreset = HapticPreset.None; + _currentDurationSeconds = 0f; } private void OnAutoStop() @@ -116,6 +146,8 @@ private void OnAutoStop() } _backend.Stop(); _isPlaying = false; + _currentPreset = HapticPreset.None; + _currentDurationSeconds = 0f; } private void CancelPendingAutoStop() diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs index 2347144..33c916e 100644 --- a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs +++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs @@ -67,65 +67,6 @@ private bool EnsureInitialized() return _vibrator != null && _vibrationEffectClass != null; } - private static (long[] timingsMs, int[] amplitudes) GetEnvelopeFor(HapticPreset preset) - { - // Time/amplitude pairs are in seconds and [0,1] amplitude (matching Lofelt's HapticPatterns - // shape). Translated to (long[] millis, int[] 0..255 amplitudes) for VibrationEffect. - float[] timesSec; float[] amps; - switch (preset) - { - case HapticPreset.Selection: - timesSec = new[] { 0.04f }; - amps = new[] { 0.471f }; - break; - case HapticPreset.Success: - timesSec = new[] { 0.04f, 0.04f, 0.16f }; - amps = new[] { 0.157f, 0.0f, 1.000f }; - break; - case HapticPreset.Warning: - timesSec = new[] { 0.12f, 0.12f, 0.04f }; - amps = new[] { 1.000f, 0.0f, 0.470f }; - break; - case HapticPreset.Error: - timesSec = new[] { 0.08f, 0.04f, 0.08f, 0.04f, 0.16f, 0.04f, 0.04f }; - amps = new[] { 0.470f, 0.0f, 0.470f, 0.0f, 1.000f, 0.0f, 0.157f }; - break; - case HapticPreset.ImpactLight: - timesSec = new[] { 0.04f }; - amps = new[] { 0.156f }; - break; - case HapticPreset.ImpactMedium: - timesSec = new[] { 0.08f }; - amps = new[] { 0.471f }; - break; - case HapticPreset.ImpactHeavy: - timesSec = new[] { 0.16f }; - amps = new[] { 1.000f }; - break; - case HapticPreset.ImpactRigid: - timesSec = new[] { 0.04f }; - amps = new[] { 1.000f }; - break; - case HapticPreset.ImpactSoft: - timesSec = new[] { 0.16f }; - amps = new[] { 0.156f }; - break; - default: - timesSec = new[] { 0.0f }; - amps = new[] { 0.0f }; - break; - } - - var timingsMs = new long[timesSec.Length]; - var amplitudes = new int [amps.Length]; - for (int i = 0; i < timesSec.Length; i++) - { - timingsMs[i] = (long)Mathf.Round(timesSec[i] * 1000f); - amplitudes[i] = Mathf.Clamp(Mathf.RoundToInt(amps[i] * 255f), 0, 255); - } - return (timingsMs, amplitudes); - } - private void PlayWaveform(HapticPreset preset, int repeatIndex) { if (!EnsureInitialized() || preset == HapticPreset.None) @@ -133,7 +74,9 @@ private void PlayWaveform(HapticPreset preset, int repeatIndex) return; } - var (timingsMs, amplitudes) = GetEnvelopeFor(preset); + // Envelope tables live in HapticEnvelopes so the editor explorer can reuse the + // exact same (timings, amplitudes) the device receives. + var (timingsMs, amplitudes) = HapticEnvelopes.GetEnvelopeFor(preset); try { using var effect = _vibrationEffectClass.CallStatic( diff --git a/Runtime/Haptics/Internal/HapticEnvelopes.cs b/Runtime/Haptics/Internal/HapticEnvelopes.cs new file mode 100644 index 0000000..1792489 --- /dev/null +++ b/Runtime/Haptics/Internal/HapticEnvelopes.cs @@ -0,0 +1,71 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Haptics.Internal +{ + /// + /// Single source of truth for per-preset haptic envelopes — read by both + /// and the editor envelope visualiser. + /// + internal static class HapticEnvelopes + { + /// Source-of-truth envelope for a preset, in seconds and [0, 1] amplitude. + internal static (float[] timesSec, float[] amps) GetFloatEnvelopeFor(HapticPreset preset) + { + switch (preset) + { + case HapticPreset.Selection: + return (new[] { 0.04f }, new[] { 0.471f }); + case HapticPreset.Success: + return (new[] { 0.04f, 0.04f, 0.16f }, new[] { 0.157f, 0.0f, 1.000f }); + case HapticPreset.Warning: + return (new[] { 0.12f, 0.12f, 0.04f }, new[] { 1.000f, 0.0f, 0.470f }); + case HapticPreset.Error: + return (new[] { 0.08f, 0.04f, 0.08f, 0.04f, 0.16f, 0.04f, 0.04f }, + new[] { 0.470f, 0.0f, 0.470f, 0.0f, 1.000f, 0.0f, 0.157f }); + case HapticPreset.ImpactLight: + return (new[] { 0.04f }, new[] { 0.156f }); + case HapticPreset.ImpactMedium: + return (new[] { 0.08f }, new[] { 0.471f }); + case HapticPreset.ImpactHeavy: + return (new[] { 0.16f }, new[] { 1.000f }); + case HapticPreset.ImpactRigid: + return (new[] { 0.04f }, new[] { 1.000f }); + case HapticPreset.ImpactSoft: + return (new[] { 0.16f }, new[] { 0.156f }); + default: + return (new[] { 0.0f }, new[] { 0.0f }); + } + } + + /// + /// Returns the runtime-ready (long[] milliseconds, int[] 0..255 amplitudes) for the + /// given preset. Mirrors what passes to + /// VibrationEffect.createWaveform. + /// + internal static (long[] timingsMs, int[] amplitudes) GetEnvelopeFor(HapticPreset preset) + { + var (timesSec, amps) = GetFloatEnvelopeFor(preset); + var timingsMs = new long[timesSec.Length]; + var amplitudes = new int[amps.Length]; + for (var i = 0; i < timesSec.Length; i++) + { + timingsMs[i] = (long)Mathf.Round(timesSec[i] * 1000f); + amplitudes[i] = Mathf.Clamp(Mathf.RoundToInt(amps[i] * 255f), 0, 255); + } + return (timingsMs, amplitudes); + } + + /// Total natural duration of the preset in seconds. + internal static float GetNaturalDurationSeconds(HapticPreset preset) + { + var (timesSec, _) = GetFloatEnvelopeFor(preset); + var total = 0f; + for (var i = 0; i < timesSec.Length; i++) + { + total += timesSec[i]; + } + return total; + } + } +} diff --git a/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta b/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta new file mode 100644 index 0000000..99a9790 --- /dev/null +++ b/Runtime/Haptics/Internal/HapticEnvelopes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a289c7bca9e774c3ca47bd5b992b9d6e \ No newline at end of file diff --git a/Runtime/IMobileService.cs b/Runtime/IMobileService.cs new file mode 100644 index 0000000..5452987 --- /dev/null +++ b/Runtime/IMobileService.cs @@ -0,0 +1,81 @@ +using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Haptics; +using GameLovers.MobileServices.NativeUi; +using GameLovers.MobileServices.Notifications; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices +{ + /// + /// Umbrella facade aggregating every Mobile Services subsystem behind a single DI registration. + /// Mirrors the design of at the package-wide level — each child is + /// also independently registerable / mockable. + /// + /// + /// Gestures are NOT exposed here. GestureController is a MonoBehaviour consumers + /// attach to a scene GameObject; surfacing it through a service-locator-style facade would create + /// the wrong mental model (the gesture surface is per-scene, not per-app). + /// + public interface IMobileService + { + /// Native UI (alerts, toasts, share, review). + INativeUiService NativeUi { get; } + + /// Local + remote notifications. + INotificationService Notifications { get; } + + /// Cross-platform haptic feedback. + IHapticsService Haptics { get; } + + /// Device sub-services (safe area, battery, connectivity, permissions, ATT, deep link, …). + IDeviceService Device { get; } + } + + /// + /// Default implementation. Constructs every child internally using + /// each subsystem's default constructor — adequate for the common case. Tests should construct + /// the children themselves and pass them through the injection constructor. + /// + public sealed class MobileService : IMobileService, System.IDisposable + { + /// + public INativeUiService NativeUi { get; } + /// + public INotificationService Notifications { get; } + /// + public IHapticsService Haptics { get; } + /// + public IDeviceService Device { get; } + + /// + /// Default constructor. Constructs NativeUiServiceInstance, HapticsService, + /// DeviceService, and a MobileNotificationService with a single channel named + /// "default". For multi-channel setups, use the injection constructor instead. + /// + public MobileService() : this( + new NativeUiServiceInstance(), + new MobileNotificationService(new GameNotificationChannel("default", "Default", "Default notifications")), + new HapticsService(), + new DeviceService()) + { + } + + /// Injection constructor — tests pass mocks via this overload. + public MobileService( + INativeUiService nativeUi, + INotificationService notifications, + IHapticsService haptics, + IDeviceService device) + { + NativeUi = nativeUi; + Notifications = notifications; + Haptics = haptics; + Device = device; + } + + public void Dispose() + { + (Device as System.IDisposable)?.Dispose(); + } + } +} diff --git a/Runtime/IMobileService.cs.meta b/Runtime/IMobileService.cs.meta new file mode 100644 index 0000000..c57743f --- /dev/null +++ b/Runtime/IMobileService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 243e8671a339540d0a9a52be1666f6c5 \ No newline at end of file diff --git a/Runtime/NativeUi/INativeUiService.cs b/Runtime/NativeUi/INativeUiService.cs new file mode 100644 index 0000000..d13804a --- /dev/null +++ b/Runtime/NativeUi/INativeUiService.cs @@ -0,0 +1,46 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.NativeUi +{ + /// + /// Instance-based wrapper over for consumers who want to mock / + /// inject the native UI surface. The static remains the primary + /// API surface and stays the recommended path; this interface only exists to unblock test + /// substitution. + /// + public interface INativeUiService + { + /// + void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons); + + /// + void ShowToastMessage(string message, bool isLongDuration); + + /// + void RequestReview(); + + /// + void Share(string text, string url = null, string imagePath = null, string title = null); + } + + /// + /// Default implementation that forwards every call to the existing static + /// . Plain class, no fields, safe to construct any number of times. + /// + public sealed class NativeUiServiceInstance : INativeUiService + { + /// + public void ShowAlertPopUp(bool isAlertSheet, string title, string message, params AlertButton[] buttons) + => NativeUiService.ShowAlertPopUp(isAlertSheet, title, message, buttons); + + /// + public void ShowToastMessage(string message, bool isLongDuration) + => NativeUiService.ShowToastMessage(message, isLongDuration); + + /// + public void RequestReview() => NativeUiService.RequestReview(); + + /// + public void Share(string text, string url = null, string imagePath = null, string title = null) + => NativeUiService.Share(text, url, imagePath, title); + } +} diff --git a/Runtime/NativeUi/INativeUiService.cs.meta b/Runtime/NativeUi/INativeUiService.cs.meta new file mode 100644 index 0000000..b3e29f5 --- /dev/null +++ b/Runtime/NativeUi/INativeUiService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4f22f5eae86746f9bf70d87073dce82 \ No newline at end of file diff --git a/Runtime/Notifications/MobileNotificationService.cs b/Runtime/Notifications/MobileNotificationService.cs index dcb0ed8..080780b 100644 --- a/Runtime/Notifications/MobileNotificationService.cs +++ b/Runtime/Notifications/MobileNotificationService.cs @@ -69,6 +69,7 @@ public interface INotificationService public class MobileNotificationService : INotificationService { private readonly GameNotificationsMonoBehaviour _monoBehaviour; + private readonly GameNotificationChannel[] _channels; /// public event Action OnLocalNotificationDeliveredEvent; @@ -77,9 +78,18 @@ public class MobileNotificationService : INotificationService /// public IReadOnlyList PendingNotifications => _monoBehaviour.PendingNotifications; - + + /// The queueing / delivery mode the host MonoBehaviour was configured with. + /// Editor introspection accessor — not part of the public surface. + internal OperatingMode CurrentMode => _monoBehaviour != null ? _monoBehaviour.Mode : OperatingMode.NoQueue; + + /// The channels passed to the constructor (Android default-channel-id resolution + Explorer display). + /// Editor introspection accessor — not part of the public surface. + internal IReadOnlyList Channels => _channels; + public MobileNotificationService(params GameNotificationChannel[] channels) { + _channels = channels ?? Array.Empty(); _monoBehaviour = new GameObject("NotificationService").AddComponent(); _monoBehaviour.OnLocalNotificationDelivered = OnLocalNotificationDeliveredEvent; _monoBehaviour.OnLocalNotificationExpired = OnLocalNotificationExpiredEvent; diff --git a/Runtime/Notifications/NotificationBuilder.cs b/Runtime/Notifications/NotificationBuilder.cs new file mode 100644 index 0000000..8d43a05 --- /dev/null +++ b/Runtime/Notifications/NotificationBuilder.cs @@ -0,0 +1,84 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Notifications +{ + /// + /// Fluent builder over + + /// . Removes the 6-line property-assignment + /// boilerplate from the common case: + /// + /// service.Schedule() + /// .In(TimeSpan.FromHours(24)) + /// .Title("Daily Reward") + /// .Body("Your reward awaits!") + /// .Channel("rewards") + /// .BadgeIncrement() + /// .Send(); + /// + /// + public sealed class NotificationBuilder + { + private readonly INotificationService _service; + private readonly IGameNotification _notification; + private bool _badgeIncrement; + + internal NotificationBuilder(INotificationService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _notification = service.CreateNotification(); + } + + public NotificationBuilder Title(string title) { _notification.Title = title; return this; } + public NotificationBuilder Body(string body) { _notification.Body = body; return this; } + public NotificationBuilder Subtitle(string subtitle) { _notification.Subtitle = subtitle; return this; } + public NotificationBuilder Channel(string channelId) { _notification.Channel = channelId; return this; } + public NotificationBuilder Id(int id) { _notification.Id = id; return this; } + public NotificationBuilder BadgeNumber(int? badge) { _notification.BadgeNumber = badge; return this; } + public NotificationBuilder SmallIcon(string smallIcon) { _notification.SmallIcon = smallIcon; return this; } + public NotificationBuilder LargeIcon(string largeIcon) { _notification.LargeIcon = largeIcon; return this; } + public NotificationBuilder AutoCancel(bool shouldAutoCancel = true) { _notification.ShouldAutoCancel = shouldAutoCancel; return this; } + public NotificationBuilder At(DateTime deliveryTime) { _notification.DeliveryTime = deliveryTime; return this; } + public NotificationBuilder In(TimeSpan delay) { _notification.DeliveryTime = DateTime.Now + delay; return this; } + + /// + /// Marks the notification for badge-increment behaviour. The underlying + /// auto-increments badge numbers when none of the + /// pending notifications have one set; calling this leaves + /// unset so the auto-increment path engages. + /// + public NotificationBuilder BadgeIncrement() + { + _badgeIncrement = true; + _notification.BadgeNumber = null; + return this; + } + + /// + /// Schedules the built notification and returns the resulting . + /// + public PendingNotification Send() + { + // _badgeIncrement is reserved for future behaviour flips; the auto-increment path on the + // host MonoBehaviour fires when BadgeNumber == null on every queued entry, so simply leaving + // it null (the default once BadgeIncrement is called) is sufficient today. + _ = _badgeIncrement; + return _service.ScheduleNotification(_notification); + } + } + + /// + /// Extension methods exposing the fluent builder on . + /// + public static class NotificationServiceExtensions + { + /// + /// Returns a new backed by this service. Each call constructs + /// a fresh notification via . + /// + public static NotificationBuilder Schedule(this INotificationService service) + { + return new NotificationBuilder(service); + } + } +} diff --git a/Runtime/Notifications/NotificationBuilder.cs.meta b/Runtime/Notifications/NotificationBuilder.cs.meta new file mode 100644 index 0000000..02b0105 --- /dev/null +++ b/Runtime/Notifications/NotificationBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 631ece187646d48be8c8e13328f98d3e \ No newline at end of file diff --git a/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs b/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs new file mode 100644 index 0000000..3b5f195 --- /dev/null +++ b/Samples~/DeepLinkRouter/DeepLinkRouterUI.cs @@ -0,0 +1,168 @@ +using System; +using GameLovers.MobileServices.Device; +using UnityEngine; +using UnityEngine.UI; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Samples.DeepLinkRouter +{ + /// + /// pattern sample with cold-start replay demonstration. See the + /// per-sample README.md. + /// + public sealed class DeepLinkRouterUI : MonoBehaviour + { + private DeepLinkService _deepLink; + private GameLovers.MobileServices.Device.DeepLinkRouter _router; + private Text _log; + private Text _coldStartLabel; + + private void Awake() + { + _deepLink = new DeepLinkService(); + _router = new GameLovers.MobileServices.Device.DeepLinkRouter(_deepLink); + + _router.MapRoute("/promo/:id", (uri, p) => + Log($"[promo] id={p["id"]} (full: {uri})")); + _router.MapRoute("/profile/:userId", (uri, p) => + Log($"[profile] userId={p["userId"]} (full: {uri})")); + _router.MapRoute("/settings", (uri, p) => + Log($"[settings] (full: {uri})")); + } + + private void Start() + { + BuildUi(); + // Cold-start link replay path — if the OS handed the app a launch URL, + // the DeepLinkService will replay it to the first subscriber. The router IS the + // first subscriber (constructed in Awake), so the replay automatically dispatches. + if (_deepLink.PendingColdStartLink != null) + { + Log($"Pending cold-start link queued: {_deepLink.PendingColdStartLink}"); + } + } + + private void OnDestroy() + { + _router?.Dispose(); + _deepLink?.Dispose(); + } + + private void Update() + { + if (_coldStartLabel == null) return; + _coldStartLabel.text = _deepLink.PendingColdStartLink != null + ? $"Cold-start: {_deepLink.PendingColdStartLink}" + : "Cold-start: (none — link was already consumed or absent)"; + } + + private void BuildUi() + { + var canvasGo = new GameObject("Canvas"); + var canvas = canvasGo.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvasGo.AddComponent().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + canvasGo.AddComponent(); + + var layoutGo = new GameObject("Layout", typeof(RectTransform), typeof(VerticalLayoutGroup)); + layoutGo.transform.SetParent(canvas.transform, false); + var rt = (RectTransform)layoutGo.transform; + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = new Vector2(16, 16); + rt.offsetMax = new Vector2(-16, -16); + var v = layoutGo.GetComponent(); + v.spacing = 8; + v.childForceExpandHeight = false; + v.childForceExpandWidth = true; + + AddHeader(layoutGo.transform, "Deep Link Router"); + AddLabel(layoutGo.transform, + "Registered routes: /promo/:id /profile/:userId /settings"); + _coldStartLabel = AddLabel(layoutGo.transform, "Cold-start: …"); + + AddSectionHeader(layoutGo.transform, "Try without launching from the OS"); + AddButton(layoutGo.transform, "Dispatch myapp://promo/spring2026", () => + _router.TryDispatch(new Uri("myapp://promo/spring2026"))); + AddButton(layoutGo.transform, "Dispatch myapp://profile/abc123", () => + _router.TryDispatch(new Uri("myapp://profile/abc123"))); + AddButton(layoutGo.transform, "Dispatch myapp://settings", () => + _router.TryDispatch(new Uri("myapp://settings"))); + AddButton(layoutGo.transform, "Dispatch unmatched URI", () => + { + var ok = _router.TryDispatch(new Uri("myapp://unknown/path")); + Log($"Unmatched dispatch returned: {ok}"); + }); + + _log = AddLabel(layoutGo.transform, "Log:"); + } + + private void Log(string message) + { + Debug.Log($"[DeepLinkRouter] {message}"); + if (_log == null) return; + var lines = _log.text?.Split('\n') ?? Array.Empty(); + var keep = Math.Max(0, lines.Length - 8); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Log:"); + for (var i = keep; i < lines.Length; i++) sb.AppendLine(lines[i]); + sb.AppendLine(message); + _log.text = sb.ToString(); + } + + // ---- UI helpers ---- + private static void AddHeader(Transform parent, string text) + { + var t = AddLabel(parent, text); + t.fontSize = 22; + t.fontStyle = FontStyle.Bold; + } + + private static void AddSectionHeader(Transform parent, string text) + { + var t = AddLabel(parent, text); + t.fontSize = 16; + t.fontStyle = FontStyle.Bold; + t.color = new Color(0.8f, 0.9f, 1f); + } + + private static Text AddLabel(Transform parent, string text) + { + var go = new GameObject("Label", typeof(Text)); + go.transform.SetParent(parent, false); + var t = go.GetComponent(); + t.text = text; + t.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + t.fontSize = 13; + t.color = Color.white; + t.alignment = TextAnchor.UpperLeft; + go.AddComponent().minHeight = 18; + return t; + } + + private static Button AddButton(Transform parent, string label, Action onClick) + { + var go = new GameObject(label, typeof(Image), typeof(Button)); + go.transform.SetParent(parent, false); + go.GetComponent().color = new Color(0.2f, 0.4f, 0.6f, 0.85f); + var btn = go.GetComponent