diff --git a/AGENTS.md b/AGENTS.md index 931fdbf..3743b5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This guide adds package-specific rules to the host repository guide. Consumer us ## Scope - Package: `com.gamelovers.mobileservices`; minimum Unity version and dependencies are authoritative in `package.json`. -- Runtime subsystems: Native UI, notifications, gestures, haptics, and device services (safe area, battery, audio session, permissions, ATT, and deep links). +- Runtime subsystems: Native UI, notifications, gestures, haptics, device services (battery, audio session, permissions, ATT, deep links, and background tasks), and the opt-in game platform (Game Center and Play Games sign-in, leaderboards, achievements, and cloud saves). - Consumers must enable the Input System or Both. The gesture contract uses EnhancedTouch. - This package is render-pipeline-neutral and has no dependency on GameLovers Services. @@ -24,13 +24,29 @@ This guide adds package-specific rules to the host repository guide. Consumer us - `MobileNotificationService` owns its `NotificationService` GameObject. Disposal is idempotent, releases only owned resources, and public operations depending on the host throw `ObjectDisposedException` afterward. Shared validation and lifecycle behavior stay outside platform conditionals. - Android notification scheduling requires a registered default channel. Queue modes hand pending work to the OS on background transitions. - Every `GestureController` balances only its own `EnhancedTouchSupport.Enable()` acquisition. Multiple live controllers and external release must remain safe. +- `IGameNotification.Data` maps to `AndroidNotification.IntentData` and `iOSNotification.Data` and must survive the queue-and-reschedule persistence. Both platforms keep returning the last opened notification, so `GameNotificationsMonoBehaviour` captures the cold-start one before the first foregrounding and de-duplicates later opens by id (title and body when unset). Opened-notification behavior is exercised through the internal `AttachPlatform` seam with a fake platform. +- Android image sharing goes through the package FileProvider: the Java class `GameLoversFileProvider` (`Plugins/Android/`), the authority suffix `NativeUiService.AndroidShareProviderAuthoritySuffix`, and the postprocessor's provider and `res/xml` paths file change together. Without the toggle an image share degrades to text with a warning. +- `NativeMediaService` needs no permission. iOS saves through `PHPhotoLibrary` only when `NSPhotoLibraryAddUsageDescription` is declared (`GL_PERMISSION_PHOTOS_ADD`) and picks through `PHPickerViewController`. Android saves through `MediaStore` (legacy storage below API 29) and picks through the transparent `GameLoversMediaPickerActivity`, which the postprocessor declares behind `IncludeMediaPicker`. +- Picked images are always a private copy the caller may delete. The `_GameLoversMedia*` exports, their callback signature, and the Java `ResultCallback` and `PathCallback` interfaces change together with their C# callers. +- `GamePlatformServices` is opt-in and holds one `IGamePlatformBackend` per platform. Its guards (signed-in player, non-empty id, positive steps, one shared sign-in) live in the service, not the backends. The Editor backend and `EditorGamePlatformState` are process-wide, so the simulator panel drives the consumer's own instance. +- The Android game backend talks to Play Games v2 through JNI and `OnCompleteListener` proxies so the package compiles without the SDK; the postprocessor adds the SDK only when `GamePlatform.PlayGamesProjectId` is set. +- Cloud saves share the game platform opt-in. `CloudSaveService` owns validation, availability and conflict resolution (policy, resolver, newest-first ordering) so both platforms behave alike. +- Cloud save backends only list, load every stored version, resolve to a chosen version, save, and delete, and report failures as `CloudSaveException`. The Android backend uses JNI like the sign-in backend. +- The iOS bridge (`CloudSave.m`) replies with tagged strings decoded by `CloudSaveProtocol` and holds its own `UIBackgroundTask` during saves, because Unity's main loop is paused while backgrounded. +- `Plugins/iOS/GamePlatform.m` links GameKit only when `GL_GAME_CENTER` is defined (Game Center capability on). The `_GameLoversGamePlatform*` exports and their callback signature change together with the C# callers. +- Shortcut taps travel through `DeepLinkService`. On Android the shortcut intent is an explicit `ACTION_VIEW` on the app activity that Unity reports as a deep link. On iOS `AppShortcuts.m` installs its `performActionForShortcutItem` hook in `+load`, chains to any existing implementation, and holds the URI until `DeepLinkService` collects it; an iOS shortcut with no subscriber becomes the cold-start link, unlike a runtime link. +- `OpenInAppBrowser` accepts only http and https (`SFSafariViewController` throws otherwise) and falls back to `Application.OpenURL` when Custom Tabs are unavailable. `GameLoversShortcuts.java` and the `_GameLoversShortcut*` exports change together with `AppShortcutsBridge`. +- `IBackgroundTaskService` is iOS-only in effect. `Plugins/iOS/BackgroundTask.m` must end the system task before its expiration handler returns, on the main thread; handle members are readable from any thread; the `_GameLoversBackgroundTask*` exports, C# `DllImport` signatures, and the expiration callback signature change together. Android and Editor return a pass-through handle. - One haptic play replaces the previous play and cancels its pending auto-stop. Negative preset duration loops until explicit stop; `StopCurrentHaptic()` is idempotent. - `DeviceServicesHost` is the shared polling host. Do not create per-service update GameObjects when the host can own the callback. - The first `DeepLinkService` subscriber receives a pending cold-start link once. Construct the service early; event names are not persistent state. - ATT returning `Authorized` outside iOS means “not applicable,” not an observed user decision. -- Permission, ATT, battery, safe-area, native-alert, and review Editor overrides are process-wide statics. Simulator code must install and remove them symmetrically; tests must reset overrides they touch. +- Permission, ATT, battery, native-alert, and review Editor overrides are process-wide statics. Simulator code must install and remove them symmetrically; tests must reset overrides they touch. - The iOS location permission bridge retains each `CLLocationManager` delegate until authorization changes and removes it only after callback dispatch. Do not simplify the static delegate-retention collection into a local lifetime. -- Android Photos and Notifications permissions use API-33 runtime permissions (`READ_MEDIA_IMAGES` and `POST_NOTIFICATIONS`) and short-circuit as granted on older APIs. Keep runtime checks and generated manifest entries aligned. +- `AndroidPermissionPolicy` owns which manifest permissions each `AppPermission` needs per API level and how grants fold into `PermissionStatus`; it stays platform-neutral so it is unit-tested. Keep it, the runtime request code, and `AndroidManifestToggles.GetPermissions` aligned. +- Android never reports a refusal directly: `Denied` is the persisted "requested" flag or the OS rationale flag, and requests are serialized through one gate because Android shows one dialog at a time. Background location is always a separate second request. Notifications below API 33 have no runtime permission and follow the enabled switch. +- `PermissionStatus` integer values are a native contract; `Limited` is 4. Limited covers partial photos, provisional notifications, approximate location, and foreground-only location when Always was asked. +- `Plugins/iOS/Permissions.m` is built in groups (camera, microphone, location, photos) gated by `GL_PERMISSION_`. Without `GL_PERMISSIONS_DECLARED_ONLY` every group compiles; the build postprocessor sets it and defines only the groups whose purpose string is in the final `Info.plist`. `IosPermissionGroups`, the plugin, and the postprocessor change together. Notification status is a native cache, so `Check` never blocks after the first read. ## Editor and native-build invariants @@ -53,5 +69,6 @@ This guide adds package-specific rules to the host repository guide. Consumer us ## Verification and documentation - Platform bridge changes require relevant Editor simulation plus real iOS/Android build or device evidence; unavailable platform evidence is `NOT VALIDATED`. +- Android App Link `AutoVerify` needs an http or https scheme and a host; validation rejects anything else before a build file is touched. - Update `README.md`, the relevant `docs/` page, and the canonical sample README when public behavior or sample setup changes. - Update this guide only for durable subsystem, native-boundary, build-ownership, or test conventions. diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fb22c..e9b444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ 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). +## [2.0.0] - 2026-10-22 + +**New**: +- **Game Platform**: Added opt-in `GamePlatformServices` (`IGamePlatformServices`) for Game Center and Google Play Games Services v2: sign-in, leaderboards, achievements and their native UIs, with `PlatformId` to keep per-platform ids in one value. Enable it with the `Game Center` toggle and the `Play Games Project Id` in the Mobile Services Config; the build then adds the entitlement, or the SDK and manifest entries. +- **Cloud Save**: Added `IGamePlatformServices.CloudSave` (`ICloudSaveService`) for slot-based saves on Game Center saved games and Google Play Games saved games, with `MostRecent` or `Manual` conflict resolution and typed `CloudSaveException` failures. The `Cloud Save` config toggle adds the iCloud Documents capability on iOS. +- **Permissions**: Added `PermissionStatus.Limited` for partial access (selected photos, provisional notifications, approximate location, foreground-only location) and `DeviceService.OpenAppSettings()` to send the player to the OS Settings page after a denial. +- **Media**: Added `NativeMediaService.SaveToGalleryAsync` to save an image or video to the device gallery and `PickImageAsync` to pick an image with the system picker, both without asking for the Photos permission. Android uses the new `Include Media Picker` and `Write External Storage Legacy` config toggles; iOS gallery saving needs a `PhotoLibraryAddOnly` usage description. +- **Native UI**: Added `NativeUiService.OpenInAppBrowser` for web pages in `SFSafariViewController` or Chrome Custom Tabs (the build adds `androidx.browser` unless `Include Custom Tabs Dependency` is turned off), and `ShareScreenshotAsync` to share the current frame. +- **Notifications**: Added `IGameNotification.Data` and `NotificationBuilder.Data` for a payload such as a deep-link URI, `INotificationService.OnNotificationOpened` for taps while the app runs, and `TryGetLaunchNotification` for the notification that started the app. +- **App Shortcuts**: Added `DeviceService.SetShortcuts` and `ClearShortcuts` for home-screen quick actions whose taps arrive through `IDeepLinkService`, including as the cold-start link. +- **Background Tasks**: Added `IDeviceService.BackgroundTasks` (`IBackgroundTaskService`) to finish short work when the app moves to the background on iOS; it is a pass-through on Android. +- **Store Links**: Added `DeviceService.OpenStorePage` to open the App Store or Google Play page, optionally the iOS write-review sheet. +- **Deep Links**: Android intent-filter registrations can set `AutoVerify` so https links open as verified App Links. +- **Mobile Services Config**: The Inspector is grouped by feature and each section lists the native build entries it adds. New Android manifest toggles cover coarse and background location, legacy storage access, the share FileProvider and the media picker. +- **Haptics**: Added an `Enable Haptics Debug Logs` config toggle; Editor haptics logs are disabled by default. + +**Changed**: +- **Breaking**: `IGameNotification`, `INotificationService`, `INativeUiService` and `IDeviceService` gained members, and the `DeviceService` injection constructor takes an `IBackgroundTaskService`. Custom implementations and mocks must add them. +- iOS builds compile only the permission code whose usage description is declared, so a game that never requests location, camera, microphone or photos no longer references those APIs. `Manage Native Build Manually` compiles every permission group. +- The package depends on the built-in `com.unity.modules.screencapture` and `com.unity.modules.imageconversion` modules. + +**Fixed**: +- Android permissions now request precise and approximate location together, request background location separately, report `Denied` after a refusal, use the correct permissions below API 33, and no longer prompt for storage just to save to the gallery. +- Android image sharing works on Android 7+ through a package FileProvider (enable `Include Share FileProvider`); previously it used a `file://` URI the OS rejects. +- iOS notification status checks no longer block the main thread, and location permission delegates are released after delivery. +- Declared the iOS framework dependencies of the native bridges so a generated Xcode project links. +- Retained the Android activity and review manager Java references until the async `requestReviewFlow` callback completes, preventing a `NullReferenceException` in `launchReviewFlow`. + +**Removed**: +- Removed `ISafeAreaService`, `SafeAreaService`, and the `SafeAreaContainer` UI Toolkit element; `IDeviceService` no longer exposes a `SafeArea` child and `EditorPlatformSimulator` no longer exposes safe-area overrides. For safe-area UI padding, use `com.gamelovers.uiservice` (`SafeAreaPanelView`/`SafeAreaContainer`). + +**Docs**: +- The root README is now the documentation index and lists common issues. `docs/build-pipeline.md` is now `docs/build-configuration.md`, `docs/explorer.md` is now `docs/device-simulator.md`, and `docs/README.md` is removed. + +**Migration**: +- Replace `IDeviceService.SafeArea` and `SafeAreaContainer` with `com.gamelovers.uiservice` `SafeAreaPanelView`/`SafeAreaContainer`. +- Add `Data`, `OnNotificationOpened`, `TryGetLaunchNotification`, `BackgroundTasks` and the new `INativeUiService` methods to custom implementations and mocks, and pass an `IBackgroundTaskService` to the `DeviceService` constructor. +- Treat `PermissionStatus.Limited` as usable access in code that switches on `PermissionStatus`. +- On iOS, declare a usage description in the Mobile Services Config for every permission the game requests; undeclared groups are no longer compiled in. +- `AccessFineLocation` now also declares `ACCESS_COARSE_LOCATION`, which Android 12+ requires; add `AccessBackgroundLocation` for `AppPermission.LocationAlways`. + ## [1.2.0] - 2026-08-14 **New**: diff --git a/CLAUDE.md b/CLAUDE.md index 12e6eb0..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1 @@ -# 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/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs b/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs new file mode 100644 index 0000000..af74c54 --- /dev/null +++ b/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs @@ -0,0 +1,89 @@ +using System.Text; +using GameLovers.MobileServices.GamePlatform.Internal; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.DeviceSimulatorPanel +{ + /// Device Simulator foldout that drives the process-wide Editor cloud save simulation. + internal sealed class CloudSaveSimulatorSection : Foldout + { + private readonly Toggle _available; + private readonly Label _list; + private readonly Label _status; + + public CloudSaveSimulatorSection() + { + text = "Cloud Save"; + value = false; + AddToClassList("msp-foldout"); + + var banner = new VisualElement(); + banner.AddToClassList("msp-banner"); + var bannerLabel = new Label("Saves from ICloudSaveService are stored under Library/GameLovers/SimulatedCloudSave. " + + "Sign the simulated player in under Game Platform first; saves also need cloud storage available."); + bannerLabel.AddToClassList("msp-banner-label"); + banner.Add(bannerLabel); + Add(banner); + + _available = new Toggle("Cloud storage available"); + _available.RegisterValueChangedCallback(evt => EditorCloudSaveState.Available = evt.newValue); + Add(_available); + + var slot = new TextField("Slot") { value = "slot1" }; + Add(slot); + var conflictData = new TextField("Conflicting data") { value = "saved on another device" }; + Add(conflictData); + + var inject = new Button(() => InjectConflict(slot.value, conflictData.value)) { text = "Inject conflicting version" }; + inject.style.marginTop = 2; + Add(inject); + + var clear = new Button(() => + { + EditorCloudSaveState.DeleteAll(); + Refresh(); + }) { text = "Delete all simulated saves" }; + clear.style.marginTop = 2; + Add(clear); + + _list = new Label(); + _list.style.whiteSpace = WhiteSpace.Normal; + _list.style.fontSize = 10; + _list.style.marginTop = 4; + Add(_list); + + _status = new Label(); + _status.style.whiteSpace = WhiteSpace.Normal; + _status.style.fontSize = 10; + _status.style.marginTop = 4; + Add(_status); + + schedule.Execute(Refresh).Every(1000); + Refresh(); + } + + private void InjectConflict(string slot, string data) + { + var injected = EditorCloudSaveState.InjectConflict(slot, Encoding.UTF8.GetBytes(data)); + _status.text = injected + ? $"Injected a newer conflicting version of '{slot}'. The next load resolves it by the conflict policy." + : $"Slot '{slot}' has no save to conflict with; save it from the game first."; + } + + private void Refresh() + { + _available.SetValueWithoutNotify(EditorCloudSaveState.Available); + var slots = EditorCloudSaveState.ListSlots(); + if (slots.Count == 0) + { + _list.text = "No simulated saves."; + return; + } + + var builder = new StringBuilder("Saved slots:"); + foreach (var item in slots) builder.Append($"\n- {item.Name} ({item.ModifiedUtc.ToLocalTime():HH:mm:ss})"); + _list.text = builder.ToString(); + } + } +} diff --git a/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs.meta b/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs.meta new file mode 100644 index 0000000..db766a5 --- /dev/null +++ b/Editor/Explorer/DeviceSimulatorPanel/CloudSaveSimulatorSection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46c3d7249f8ee4661b3b6de9746b8801 \ No newline at end of file diff --git a/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs b/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs new file mode 100644 index 0000000..7e89c84 --- /dev/null +++ b/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs @@ -0,0 +1,73 @@ +using GameLovers.MobileServices.GamePlatform.Internal; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.Explorer.DeviceSimulatorPanel +{ + /// Device Simulator foldout that drives the process-wide Editor game-service simulation. + internal sealed class GamePlatformSimulatorSection : Foldout + { + private readonly Toggle _signedIn; + private readonly Toggle _signInSucceeds; + private readonly TextField _playerName; + private readonly Label _activity; + + public GamePlatformSimulatorSection() + { + text = "Game Platform"; + value = false; + AddToClassList("msp-foldout"); + + var banner = new VisualElement(); + banner.AddToClassList("msp-banner"); + var bannerLabel = new Label("Simulates Game Center and Google Play Games for GamePlatformServices in the Editor. " + + "Sign-in, scores and achievements are recorded below instead of reaching a platform."); + bannerLabel.AddToClassList("msp-banner-label"); + banner.Add(bannerLabel); + Add(banner); + + _signedIn = new Toggle("Signed in"); + _signedIn.RegisterValueChangedCallback(evt => + { + if (evt.newValue != EditorGamePlatformState.SignedIn) EditorGamePlatformState.SetSignedIn(evt.newValue); + }); + Add(_signedIn); + + _signInSucceeds = new Toggle("Next sign-in succeeds"); + _signInSucceeds.RegisterValueChangedCallback(evt => EditorGamePlatformState.SignInSucceeds = evt.newValue); + Add(_signInSucceeds); + + _playerName = new TextField("Player name"); + _playerName.RegisterValueChangedCallback(evt => EditorGamePlatformState.PlayerName = evt.newValue); + Add(_playerName); + + _activity = new Label(); + _activity.style.whiteSpace = WhiteSpace.Normal; + _activity.style.fontSize = 10; + _activity.style.marginTop = 4; + Add(_activity); + + var reset = new Button(EditorGamePlatformState.Reset) { text = "Sign out and clear activity" }; + reset.style.marginTop = 2; + Add(reset); + + RegisterCallback(_ => + { + EditorGamePlatformState.Changed += Refresh; + Refresh(); + }); + RegisterCallback(_ => EditorGamePlatformState.Changed -= Refresh); + Refresh(); + } + + private void Refresh() + { + _signedIn.SetValueWithoutNotify(EditorGamePlatformState.SignedIn); + _signInSucceeds.SetValueWithoutNotify(EditorGamePlatformState.SignInSucceeds); + _playerName.SetValueWithoutNotify(EditorGamePlatformState.PlayerName); + _activity.text = EditorGamePlatformState.Activity.Count == 0 + ? "No game-service activity yet." + : "Recent activity:\n- " + string.Join("\n- ", EditorGamePlatformState.Activity); + } + } +} diff --git a/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs.meta b/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs.meta new file mode 100644 index 0000000..3b46093 --- /dev/null +++ b/Editor/Explorer/DeviceSimulatorPanel/GamePlatformSimulatorSection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8d4bc9d8b2f634735802c1baf91499d9 \ No newline at end of file diff --git a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs index ae1091d..e1db5ca 100644 --- a/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs +++ b/Editor/Explorer/DeviceSimulatorPanel/MobileServicesDeviceSimulatorPlugin.cs @@ -149,6 +149,8 @@ public override VisualElement OnCreateUI() _sectionsContainer.Add(BuildGesturesSection()); _sectionsContainer.Add(BuildPermissionsSection()); _sectionsContainer.Add(BuildAttSection()); + _sectionsContainer.Add(new GamePlatformSimulatorSection()); + _sectionsContainer.Add(new CloudSaveSimulatorSection()); scroll.Add(_sectionsContainer); root.Add(scroll); diff --git a/Editor/Explorer/Overlays/MobileSimulatorState.cs b/Editor/Explorer/Overlays/MobileSimulatorState.cs index 45648ff..fbbb2f7 100644 --- a/Editor/Explorer/Overlays/MobileSimulatorState.cs +++ b/Editor/Explorer/Overlays/MobileSimulatorState.cs @@ -75,6 +75,7 @@ public sealed class SimulatedNotificationBannerSpec public string Title; public string Body; public string SubTitle; + public Action OnTapped; // Optional: raised when the player taps the banner. } /// Payload for a simulated permission or ATT prompt, including the callback that resolves it. @@ -88,7 +89,7 @@ public sealed class SimulatedPermissionDialogSpec /// /// Editor-only broker decoupling the Device Simulator plugin and EditorPlatformSimulator - /// from the in-Game-view overlay that paints the mock dialogs. See docs/explorer.md. + /// from the in-Game-view overlay that paints the mock dialogs. See docs/device-simulator.md. /// /// /// There is now a single renderer surface (the MobileSimulatorRuntimeOverlay), so push diff --git a/Editor/Explorer/Overlays/MockBuilders.cs b/Editor/Explorer/Overlays/MockBuilders.cs index 89d7c94..904b7c5 100644 --- a/Editor/Explorer/Overlays/MockBuilders.cs +++ b/Editor/Explorer/Overlays/MockBuilders.cs @@ -252,6 +252,10 @@ internal static VisualElement BuildNotificationBanner(SimulatedPlatform platform content.Add(body); card.Add(content); + if (spec.OnTapped != null) + { + card.RegisterCallback(_ => spec.OnTapped()); + } wrapper.Add(card); return wrapper; } diff --git a/Editor/NativeBuild/IosPermissionGroups.cs b/Editor/NativeBuild/IosPermissionGroups.cs new file mode 100644 index 0000000..f506526 --- /dev/null +++ b/Editor/NativeBuild/IosPermissionGroups.cs @@ -0,0 +1,17 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Editor.NativeBuild +{ + /// The iOS permission groups of the native bridges and the Info.plist keys that declare each. + internal static class IosPermissionGroups + { + /// Each define gates one native group, which compiles when any of its plist keys is present. + public static readonly (string Define, string Name, string[] PlistKeys)[] All = + { + ("GL_PERMISSION_CAMERA", "camera", new[] { "NSCameraUsageDescription" }), + ("GL_PERMISSION_MICROPHONE", "microphone", new[] { "NSMicrophoneUsageDescription" }), + ("GL_PERMISSION_LOCATION", "location", new[] { "NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription" }), + ("GL_PERMISSION_PHOTOS", "photos", new[] { "NSPhotoLibraryUsageDescription" }), + ("GL_PERMISSION_PHOTOS_ADD", "photos add-only", new[] { "NSPhotoLibraryAddUsageDescription" }), + }; + } +} diff --git a/Editor/NativeBuild/IosPermissionGroups.cs.meta b/Editor/NativeBuild/IosPermissionGroups.cs.meta new file mode 100644 index 0000000..1a30c10 --- /dev/null +++ b/Editor/NativeBuild/IosPermissionGroups.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 068851438695e49adae3b06e883195dd \ No newline at end of file diff --git a/Editor/NativeBuild/MobileServicesBuildPostprocessor.cs b/Editor/NativeBuild/MobileServicesBuildPostprocessor.cs index 0ff5970..fc46e26 100644 --- a/Editor/NativeBuild/MobileServicesBuildPostprocessor.cs +++ b/Editor/NativeBuild/MobileServicesBuildPostprocessor.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.RegularExpressions; using GameLovers.MobileServices.Editor.Settings; +using GameLovers.MobileServices.NativeUi; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -29,7 +30,14 @@ public sealed class MobileServicesBuildPostprocessor : IPostprocessBuildWithRepo { #if UNITY_ANDROID private const string PlayReviewArtifactKey = "com.google.android.play:review"; - private static readonly XNamespace AndroidNamespace = "http://schemas.android.com/apk/res/android"; + private const string ShareFileProviderClass = "com.gamelovers.mobileservices.GameLoversFileProvider"; + private const string ShareFileProviderPathsResource = "gamelovers_file_paths"; + private const string MediaPickerActivityClass = "com.gamelovers.mobileservices.GameLoversMediaPickerActivity"; + private const string CustomTabsArtifactKey = "androidx.browser:browser"; + private const string PlayGamesArtifactKey = "com.google.android.gms:play-services-games-v2"; + private const string PlayGamesAppIdMetaData = "com.google.android.gms.games.APP_ID"; + private const string PlayGamesProjectIdResource = "gamelovers_play_games_project_id"; + private static readonly XNamespace _androidNamespace = "http://schemas.android.com/apk/res/android"; #endif /// @@ -60,41 +68,16 @@ public void OnPostGenerateGradleAndroidProject(string path) var warnings = scan.GetConfigurationWarnings(settings); LogScannerWarnings(warnings); if (NeedsAndroidManifestMutation(settings)) ProcessGeneratedAndroidManifest(path, settings); - if (!settings.IncludePlayReviewDependency) - { - Debug.Log($"[GameLovers.MobileServices] Native build summary: platform=Android source={DescribeSource(settings)} permissions={settings.PermissionDescriptions.Count} androidDeepLinks={settings.DeepLinks.AndroidIntentFilters.Count} playReview=disabled scannerWarnings={warnings.Count}."); - return; - } - - string[] gradleFiles; - try { gradleFiles = Directory.GetFiles(path, "*.gradle", SearchOption.AllDirectories); } - catch (Exception exception) { throw new BuildFailedException($"[GameLovers.MobileServices] Could not enumerate Gradle files under '{path}': {exception.Message}"); } - foreach (var file in gradleFiles) - { - var contents = ReadTextOrThrow(file); - if (contents.Contains(PlayReviewArtifactKey, StringComparison.Ordinal)) - { - Debug.Log($"[GameLovers.MobileServices] '{PlayReviewArtifactKey}' already exists in '{file}' — preserving the existing dependency owner."); - Debug.Log($"[GameLovers.MobileServices] Native build summary: platform=Android source={DescribeSource(settings)} permissions={settings.PermissionDescriptions.Count} androidDeepLinks={settings.DeepLinks.AndroidIntentFilters.Count} playReview=existing scannerWarnings={warnings.Count}."); - return; - } - } - - var targetGradle = FindModuleBuildGradle(path, gradleFiles); - if (targetGradle == null) - { - throw new BuildFailedException($"[GameLovers.MobileServices] Could not find a Unity Android module with a dependencies block under '{path}'. Add implementation '{settings.PlayReviewDependencyCoordinate}' manually or disable Include Play Review Dependency."); - } - - var injected = InsertIntoDependenciesBlock(ReadTextOrThrow(targetGradle), $"implementation '{settings.PlayReviewDependencyCoordinate}'"); - if (injected == null) - { - throw new BuildFailedException($"[GameLovers.MobileServices] No dependencies block was found in '{targetGradle}'. Add implementation '{settings.PlayReviewDependencyCoordinate}' manually."); - } - - File.WriteAllText(targetGradle, injected); - Debug.Log($"[GameLovers.MobileServices] Injected '{settings.PlayReviewDependencyCoordinate}' into '{targetGradle}'."); - Debug.Log($"[GameLovers.MobileServices] Native build summary: platform=Android source={DescribeSource(settings)} permissions={settings.PermissionDescriptions.Count} androidDeepLinks={settings.DeepLinks.AndroidIntentFilters.Count} playReview=changed scannerWarnings={warnings.Count}."); + var playReview = settings.IncludePlayReviewDependency + ? EnsureGradleDependency(path, PlayReviewArtifactKey, settings.PlayReviewDependencyCoordinate, "disable Include Play Review Dependency") + : "disabled"; + var customTabs = settings.IncludeCustomTabsDependency + ? EnsureGradleDependency(path, CustomTabsArtifactKey, settings.CustomTabsDependencyCoordinate, "disable Include Custom Tabs Dependency") + : "disabled"; + var playGames = settings.GamePlatform.PlayGamesEnabled + ? EnsureGradleDependency(path, PlayGamesArtifactKey, settings.GamePlatform.ResolvePlayGamesCoordinate(), "clear the Play Games project id") + : "disabled"; + Debug.Log($"[GameLovers.MobileServices] Native build summary: platform=Android source={DescribeSource(settings)} permissions={settings.PermissionDescriptions.Count} androidDeepLinks={settings.DeepLinks.AndroidIntentFilters.Count} playReview={playReview} customTabs={customTabs} playGames={playGames} scannerWarnings={warnings.Count}."); } #endif @@ -135,21 +118,8 @@ private static string DescribeSource(MobileServicesConfig settings) : $"asset:{path}"; } - private static bool NeedsIosMutation(MobileServicesConfig settings) - { - if (settings.Capabilities.PushNotifications || settings.Capabilities.AppTracking || settings.Capabilities.AssociatedDomains) return true; - if (settings.DeepLinks.IosUrlSchemes.Count > 0) return true; - foreach (var row in settings.PermissionDescriptions) - { - if (row?.Entries == null) continue; - if (row.Entries.Any(entry => entry != null && !string.IsNullOrWhiteSpace(entry.UsageDescription))) return true; - } - return settings.AttUsageDescription?.Entries?.Any(entry => entry != null && !string.IsNullOrWhiteSpace(entry.UsageDescription)) == true; - } - private static void PostprocessIos(BuildReport report, MobileServicesConfig settings) { - if (!NeedsIosMutation(settings)) return; #if UNITY_IOS InjectIosBuild(report, settings); #else @@ -193,7 +163,9 @@ private static void InjectIosBuild(BuildReport report, MobileServicesConfig sett var mainTargetGuid = pbx.GetUnityMainTargetGuid(); var pbxChanged = EmitLocalizedInfoPlistStrings(buildPath, settings, pbx, mainTargetGuid); - var capabilityNeeded = settings.Capabilities.PushNotifications || + ApplyNativeGroupDefines(pbx, plist.root, settings); + pbxChanged = true; + var capabilityNeeded = settings.Capabilities.PushNotifications || settings.GamePlatform.GameCenter || settings.GamePlatform.CloudSave || (settings.Capabilities.AssociatedDomains && settings.Capabilities.AssociatedDomainList.Count > 0); if (capabilityNeeded) { @@ -207,6 +179,8 @@ private static void InjectIosBuild(BuildReport report, MobileServicesConfig sett if (pbxChanged) pbx.WriteToFile(pbxPath); var capability = new ProjectCapabilityManager(pbxPath, entitlementsName, null, mainTargetGuid); if (settings.Capabilities.PushNotifications) capability.AddPushNotifications(true); + if (settings.GamePlatform.GameCenter) capability.AddGameCenter(); + if (settings.GamePlatform.CloudSave) capability.AddiCloud(false, true, false, true, null); if (settings.Capabilities.AssociatedDomains && settings.Capabilities.AssociatedDomainList.Count > 0) capability.AddAssociatedDomains(settings.Capabilities.AssociatedDomainList.ToArray()); capability.WriteToFile(); @@ -219,6 +193,19 @@ private static void InjectIosBuild(BuildReport report, MobileServicesConfig sett Debug.Log($"[GameLovers.MobileServices] iOS native processing completed for '{buildPath}'."); } + private static void ApplyNativeGroupDefines(PBXProject pbx, PlistElementDict plistRoot, MobileServicesConfig settings) + { + var add = new List { "GL_PERMISSIONS_DECLARED_ONLY=1" }; + var remove = new List(); + (settings.GamePlatform.GameCenter ? add : remove).Add("GL_GAME_CENTER=1"); + foreach (var group in IosPermissionGroups.All) + { + var declared = group.PlistKeys.Any(key => plistRoot.values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value.AsString())); + (declared ? add : remove).Add($"{group.Define}=1"); + } + pbx.UpdateBuildProperty(pbx.GetUnityFrameworkTargetGuid(), "GCC_PREPROCESSOR_DEFINITIONS", add, remove); + } + private static bool MergePlistString(PlistElementDict root, string key, string requested, string file) { if (string.IsNullOrWhiteSpace(requested)) return false; @@ -402,8 +389,11 @@ private static void MergeEntitlementDomains(string path, IReadOnlyList r #if UNITY_ANDROID private static bool NeedsAndroidManifestMutation(MobileServicesConfig settings) => - GetConfiguredAndroidPermissions(settings.AndroidManifest).Any() || + settings.AndroidManifest.GetPermissions().Any() || settings.AndroidManifest.IncludeShareQueriesBlock || + settings.AndroidManifest.IncludeShareFileProvider || + settings.AndroidManifest.IncludeMediaPicker || + settings.GamePlatform.PlayGamesEnabled || settings.DeepLinks.AndroidIntentFilters.Count > 0; private static void ProcessGeneratedAndroidManifest(string gradleProjectPath, MobileServicesConfig settings) @@ -431,13 +421,26 @@ private static void ProcessGeneratedAndroidManifest(string gradleProjectPath, Mo var root = target.document.Root; if (root == null) throw new BuildFailedException($"[GameLovers.MobileServices] Generated Android manifest '{target.path}' has no root element."); var changed = false; - foreach (var permission in GetConfiguredAndroidPermissions(settings.AndroidManifest)) + foreach (var (permission, maxSdkVersion) in settings.AndroidManifest.GetPermissions()) { if (root.Elements(root.Name.Namespace + "uses-permission").Any(element => string.Equals(GetAndroidAttribute(element, "name"), permission, StringComparison.Ordinal))) continue; - root.AddFirst(new XElement(root.Name.Namespace + "uses-permission", new XAttribute(AndroidNamespace + "name", permission))); + var declaration = new XElement(root.Name.Namespace + "uses-permission", new XAttribute(_androidNamespace + "name", permission)); + if (maxSdkVersion.HasValue) declaration.Add(new XAttribute(_androidNamespace + "maxSdkVersion", maxSdkVersion.Value)); + root.AddFirst(declaration); changed = true; } if (settings.AndroidManifest.IncludeShareQueriesBlock) changed |= AddShareQueriesIfMissing(root); + if (settings.AndroidManifest.IncludeMediaPicker) changed |= AddMediaPickerActivityIfMissing(root); + if (settings.GamePlatform.PlayGamesEnabled) + { + changed |= AddPlayGamesMetaDataIfMissing(root); + changed |= WritePlayGamesProjectId(Path.GetDirectoryName(target.path), settings.GamePlatform.PlayGamesProjectId.Trim()); + } + if (settings.AndroidManifest.IncludeShareFileProvider) + { + changed |= AddShareFileProviderIfMissing(root); + changed |= WriteShareFileProviderPaths(Path.GetDirectoryName(target.path)); + } foreach (var registration in settings.DeepLinks.AndroidIntentFilters) changed |= AddAndroidIntentFilterIfMissing(target.activity, registration); if (!changed) return; @@ -445,16 +448,6 @@ private static void ProcessGeneratedAndroidManifest(string gradleProjectPath, Mo Debug.Log($"[GameLovers.MobileServices] Patched generated Android manifest '{target.path}'."); } - private static IEnumerable GetConfiguredAndroidPermissions(AndroidManifestToggles toggles) - { - if (toggles.Camera) yield return "android.permission.CAMERA"; - if (toggles.RecordAudio) yield return "android.permission.RECORD_AUDIO"; - if (toggles.AccessFineLocation) yield return "android.permission.ACCESS_FINE_LOCATION"; - if (toggles.ReadMediaImages) yield return "android.permission.READ_MEDIA_IMAGES"; - if (toggles.PostNotifications) yield return "android.permission.POST_NOTIFICATIONS"; - if (toggles.Vibrate) yield return "android.permission.VIBRATE"; - } - private static bool AddShareQueriesIfMissing(XElement root) { var ns = root.Name.Namespace; @@ -468,26 +461,103 @@ private static bool AddShareQueriesIfMissing(XElement root) if (queries.Elements(ns + "intent").Any(intent => intent.Elements(ns + "action").Any(action => string.Equals(GetAndroidAttribute(action, "name"), "android.intent.action.SEND", StringComparison.Ordinal)) && intent.Elements(ns + "data").Any(data => string.Equals(GetAndroidAttribute(data, "mimeType"), "*/*", StringComparison.Ordinal)))) return false; - queries.Add(new XElement(ns + "intent", new XElement(ns + "action", new XAttribute(AndroidNamespace + "name", "android.intent.action.SEND")), new XElement(ns + "data", new XAttribute(AndroidNamespace + "mimeType", "*/*")))); + queries.Add(new XElement(ns + "intent", new XElement(ns + "action", new XAttribute(_androidNamespace + "name", "android.intent.action.SEND")), new XElement(ns + "data", new XAttribute(_androidNamespace + "mimeType", "*/*")))); + return true; + } + + private static bool AddPlayGamesMetaDataIfMissing(XElement root) + { + var ns = root.Name.Namespace; + var application = root.Element(ns + "application"); + if (application == null) throw new BuildFailedException("[GameLovers.MobileServices] The generated Android manifest has no element to hold the Play Games app id."); + if (application.Elements(ns + "meta-data").Any(meta => string.Equals(GetAndroidAttribute(meta, "name"), PlayGamesAppIdMetaData, StringComparison.Ordinal))) return false; + application.Add(new XElement(ns + "meta-data", + new XAttribute(_androidNamespace + "name", PlayGamesAppIdMetaData), + new XAttribute(_androidNamespace + "value", "@string/" + PlayGamesProjectIdResource))); + return true; + } + + private static bool WritePlayGamesProjectId(string manifestDirectory, string projectId) + { + var file = Path.Combine(manifestDirectory, "res", "values", PlayGamesProjectIdResource + ".xml"); + var contents = "\n\n" + + $" {projectId}\n" + + "\n"; + if (File.Exists(file) && string.Equals(ReadTextOrThrow(file), contents, StringComparison.Ordinal)) return false; + Directory.CreateDirectory(Path.GetDirectoryName(file)); + File.WriteAllText(file, contents); + return true; + } + + private static bool AddMediaPickerActivityIfMissing(XElement root) + { + var ns = root.Name.Namespace; + var application = root.Element(ns + "application"); + if (application == null) throw new BuildFailedException("[GameLovers.MobileServices] The generated Android manifest has no element to hold the media picker activity."); + if (application.Elements(ns + "activity").Any(activity => string.Equals(GetAndroidAttribute(activity, "name"), MediaPickerActivityClass, StringComparison.Ordinal))) return false; + application.Add(new XElement(ns + "activity", + new XAttribute(_androidNamespace + "name", MediaPickerActivityClass), + new XAttribute(_androidNamespace + "theme", "@android:style/Theme.Translucent.NoTitleBar"), + new XAttribute(_androidNamespace + "configChanges", "orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize|uiMode"), + new XAttribute(_androidNamespace + "exported", "false"))); + return true; + } + + private static bool AddShareFileProviderIfMissing(XElement root) + { + var ns = root.Name.Namespace; + var application = root.Element(ns + "application"); + if (application == null) throw new BuildFailedException("[GameLovers.MobileServices] The generated Android manifest has no element to hold the share FileProvider."); + if (application.Elements(ns + "provider").Any(provider => string.Equals(GetAndroidAttribute(provider, "name"), ShareFileProviderClass, StringComparison.Ordinal))) return false; + application.Add(new XElement(ns + "provider", + new XAttribute(_androidNamespace + "name", ShareFileProviderClass), + new XAttribute(_androidNamespace + "authorities", "${applicationId}" + NativeUiService.AndroidShareProviderAuthoritySuffix), + new XAttribute(_androidNamespace + "exported", "false"), + new XAttribute(_androidNamespace + "grantUriPermissions", "true"), + new XElement(ns + "meta-data", + new XAttribute(_androidNamespace + "name", "android.support.FILE_PROVIDER_PATHS"), + new XAttribute(_androidNamespace + "resource", "@xml/" + ShareFileProviderPathsResource)))); + return true; + } + + private static bool WriteShareFileProviderPaths(string manifestDirectory) + { + var file = Path.Combine(manifestDirectory, "res", "xml", ShareFileProviderPathsResource + ".xml"); + const string contents = "\n\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + if (File.Exists(file) && string.Equals(ReadTextOrThrow(file), contents, StringComparison.Ordinal)) return false; + Directory.CreateDirectory(Path.GetDirectoryName(file)); + File.WriteAllText(file, contents); return true; } private static bool AddAndroidIntentFilterIfMissing(XElement activity, AndroidDeepLinkRegistration registration) { var ns = activity.Name.Namespace; - var name = AndroidNamespace + "name"; - var matches = activity.Elements(ns + "intent-filter").Any(filter => + var name = _androidNamespace + "name"; + var existing = activity.Elements(ns + "intent-filter").FirstOrDefault(filter => filter.Elements(ns + "action").Any(action => string.Equals((string)action.Attribute(name), "android.intent.action.VIEW", StringComparison.Ordinal)) && filter.Elements(ns + "category").Any(category => string.Equals((string)category.Attribute(name), "android.intent.category.DEFAULT", StringComparison.Ordinal)) && filter.Elements(ns + "category").Any(category => string.Equals((string)category.Attribute(name), "android.intent.category.BROWSABLE", StringComparison.Ordinal)) && - filter.Elements(ns + "data").Any(data => string.Equals((string)data.Attribute(AndroidNamespace + "scheme"), registration.Scheme, StringComparison.OrdinalIgnoreCase) && - string.Equals((string)data.Attribute(AndroidNamespace + "host") ?? string.Empty, registration.Host ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - string.Equals((string)data.Attribute(AndroidNamespace + "pathPrefix") ?? string.Empty, registration.PathPrefix ?? string.Empty, StringComparison.Ordinal))); - if (matches) return false; - var data = new XElement(ns + "data", new XAttribute(AndroidNamespace + "scheme", registration.Scheme)); - if (!string.IsNullOrEmpty(registration.Host)) data.Add(new XAttribute(AndroidNamespace + "host", registration.Host)); - if (!string.IsNullOrEmpty(registration.PathPrefix)) data.Add(new XAttribute(AndroidNamespace + "pathPrefix", registration.PathPrefix)); - activity.Add(new XElement(ns + "intent-filter", new XElement(ns + "action", new XAttribute(name, "android.intent.action.VIEW")), new XElement(ns + "category", new XAttribute(name, "android.intent.category.DEFAULT")), new XElement(ns + "category", new XAttribute(name, "android.intent.category.BROWSABLE")), data)); + filter.Elements(ns + "data").Any(data => string.Equals((string)data.Attribute(_androidNamespace + "scheme"), registration.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals((string)data.Attribute(_androidNamespace + "host") ?? string.Empty, registration.Host ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + string.Equals((string)data.Attribute(_androidNamespace + "pathPrefix") ?? string.Empty, registration.PathPrefix ?? string.Empty, StringComparison.Ordinal))); + if (existing != null) + { + if (!registration.AutoVerify || string.Equals((string)existing.Attribute(_androidNamespace + "autoVerify"), "true", StringComparison.Ordinal)) return false; + existing.SetAttributeValue(_androidNamespace + "autoVerify", "true"); + return true; + } + var data = new XElement(ns + "data", new XAttribute(_androidNamespace + "scheme", registration.Scheme)); + if (!string.IsNullOrEmpty(registration.Host)) data.Add(new XAttribute(_androidNamespace + "host", registration.Host)); + if (!string.IsNullOrEmpty(registration.PathPrefix)) data.Add(new XAttribute(_androidNamespace + "pathPrefix", registration.PathPrefix)); + var filterElement = new XElement(ns + "intent-filter", new XElement(ns + "action", new XAttribute(name, "android.intent.action.VIEW")), new XElement(ns + "category", new XAttribute(name, "android.intent.category.DEFAULT")), new XElement(ns + "category", new XAttribute(name, "android.intent.category.BROWSABLE")), data); + if (registration.AutoVerify) filterElement.SetAttributeValue(_androidNamespace + "autoVerify", "true"); + activity.Add(filterElement); return true; } @@ -500,7 +570,36 @@ private static bool IsGeneratedIntermediatePath(string path) private static bool IsUnityPlayerActivity(string activityName) => !string.IsNullOrEmpty(activityName) && (activityName.EndsWith(".UnityPlayerActivity", StringComparison.Ordinal) || activityName.EndsWith(".UnityPlayerGameActivity", StringComparison.Ordinal) || string.Equals(activityName, "UnityPlayerActivity", StringComparison.Ordinal) || string.Equals(activityName, "UnityPlayerGameActivity", StringComparison.Ordinal)); - private static string GetAndroidAttribute(XElement element, string localName) => element?.Attribute(AndroidNamespace + localName)?.Value; + private static string GetAndroidAttribute(XElement element, string localName) => element?.Attribute(_androidNamespace + localName)?.Value; + + private static string EnsureGradleDependency(string path, string artifactKey, string coordinate, string optOutHint) + { + string[] gradleFiles; + try { gradleFiles = Directory.GetFiles(path, "*.gradle", SearchOption.AllDirectories); } + catch (Exception exception) { throw new BuildFailedException($"[GameLovers.MobileServices] Could not enumerate Gradle files under '{path}': {exception.Message}"); } + foreach (var file in gradleFiles) + { + if (!ReadTextOrThrow(file).Contains(artifactKey, StringComparison.Ordinal)) continue; + Debug.Log($"[GameLovers.MobileServices] '{artifactKey}' already exists in '{file}' — preserving the existing dependency owner."); + return "existing"; + } + + var targetGradle = FindModuleBuildGradle(path, gradleFiles); + if (targetGradle == null) + { + throw new BuildFailedException($"[GameLovers.MobileServices] Could not find a Unity Android module with a dependencies block under '{path}'. Add implementation '{coordinate}' manually or {optOutHint}."); + } + + var injected = InsertIntoDependenciesBlock(ReadTextOrThrow(targetGradle), $"implementation '{coordinate}'"); + if (injected == null) + { + throw new BuildFailedException($"[GameLovers.MobileServices] No dependencies block was found in '{targetGradle}'. Add implementation '{coordinate}' manually."); + } + + File.WriteAllText(targetGradle, injected); + Debug.Log($"[GameLovers.MobileServices] Injected '{coordinate}' into '{targetGradle}'."); + return "changed"; + } private static string FindModuleBuildGradle(string path, string[] gradleFiles) { diff --git a/Editor/Settings/MobileServicesConfig.cs b/Editor/Settings/MobileServicesConfig.cs index b38c413..53b2b11 100644 --- a/Editor/Settings/MobileServicesConfig.cs +++ b/Editor/Settings/MobileServicesConfig.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Haptics.Internal; using UnityEditor; using UnityEngine; @@ -66,6 +67,74 @@ public sealed class CapabilityToggles [SerializeField] public List AssociatedDomainList = new List(); } + /// + /// Game Center and Google Play Games settings for GamePlatformServices; both are off until filled in. + /// + [Serializable] + public sealed class GamePlatformSettings + { + /// Default Maven coordinate for the Play Games Services v2 SDK. + public const string DefaultPlayGamesCoordinate = "com.google.android.gms:play-services-games-v2:20.1.2"; + + /// + /// Adds the Game Center capability and compiles the GameKit bridge into iOS builds. The App ID and provisioning + /// profile must also have Game Center enabled. + /// + [SerializeField] public bool GameCenter; + + /// + /// Adds the iCloud Documents capability that Game Center saved games need on iOS. Cloud saves also need Game Center + /// and, on Android, the Play Games project id with Saved Games enabled in the Play Console. + /// + [SerializeField] public bool CloudSave; + + /// + /// The numeric Google Play Console project id. Entering it adds the Play Games Services SDK and the + /// APP_ID manifest entry to Android builds; leaving it empty keeps the SDK out. + /// + [SerializeField] public string PlayGamesProjectId; + + /// + /// Overrides the Play Games Services Maven coordinate, for example to pin a version or use an internal mirror. + /// Blank uses . + /// + [SerializeField] public string PlayGamesDependencyCoordinate; + + /// True when a Play Games project id is set, which switches the Android integration on. + public bool PlayGamesEnabled => !string.IsNullOrWhiteSpace(PlayGamesProjectId); + + /// The coordinate to inject: the override when set, otherwise the default. + public string ResolvePlayGamesCoordinate() => + string.IsNullOrWhiteSpace(PlayGamesDependencyCoordinate) ? DefaultPlayGamesCoordinate : PlayGamesDependencyCoordinate.Trim(); + + /// Appends malformed explicitly configured values to the supplied error collection. + internal void CollectValidationErrors(string prefix, List errors) + { + if (CloudSave && !GameCenter && !PlayGamesEnabled) + { + errors.Add($"{prefix}.CloudSave is enabled but neither GameCenter nor PlayGamesProjectId is set; cloud saves need a game service to sign in to."); + } + if (PlayGamesEnabled && !IsAllDigits(PlayGamesProjectId.Trim())) + { + errors.Add($"{prefix}.PlayGamesProjectId must be the numeric Play Console project id: '{PlayGamesProjectId}'."); + } + if (PlayGamesEnabled && !MobileServicesConfig.IsValidMavenCoordinate(ResolvePlayGamesCoordinate())) + { + errors.Add($"{prefix}.PlayGamesDependencyCoordinate is invalid: '{PlayGamesDependencyCoordinate}'."); + } + } + + private static bool IsAllDigits(string value) + { + if (value.Length == 0) return false; + foreach (var character in value) + { + if (character < '0' || character > '9') return false; + } + return true; + } + } + /// /// Android <uses-permission> / <queries> entries to inject into the /// manifest template. @@ -73,9 +142,17 @@ public sealed class CapabilityToggles [Serializable] public sealed class AndroidManifestToggles { - /// Adds READ_MEDIA_IMAGES (Photo Library on API 33+). + /// + /// Adds the Photo Library permissions: READ_MEDIA_IMAGES (API 33+), READ_MEDIA_VISUAL_USER_SELECTED + /// (API 34+ partial access) and READ_EXTERNAL_STORAGE up to API 32. + /// [SerializeField] public bool ReadMediaImages; + /// + /// Adds WRITE_EXTERNAL_STORAGE up to API 28, needed to save to the gallery on Android 9 and older. + /// + [SerializeField] public bool WriteExternalStorageLegacy; + /// Adds POST_NOTIFICATIONS (API 33+). [SerializeField] public bool PostNotifications; @@ -88,13 +165,57 @@ public sealed class AndroidManifestToggles /// Adds CAMERA. [SerializeField] public bool Camera; - /// Adds ACCESS_FINE_LOCATION. + /// + /// Adds ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION, which Android 12+ requires together. + /// [SerializeField] public bool AccessFineLocation; + /// Adds ACCESS_COARSE_LOCATION for apps that only need approximate location. + [SerializeField] public bool AccessCoarseLocation; + + /// + /// Adds ACCESS_BACKGROUND_LOCATION for AppPermission.LocationAlways. Google Play requires a + /// permission declaration form for it. + /// + [SerializeField] public bool AccessBackgroundLocation; + /// /// Adds the share-chooser <queries> block (Android 11+ visibility for share targets). /// [SerializeField] public bool IncludeShareQueriesBlock; + + /// + /// Declares the package FileProvider so NativeUiService.Share can send images on Android 7+, where + /// file:// URIs are rejected. + /// + [SerializeField] public bool IncludeShareFileProvider; + + /// + /// Declares the transparent picker activity NativeMediaService.PickImageAsync uses on Android. The + /// system picker needs no permission. + /// + [SerializeField] public bool IncludeMediaPicker; + + /// + /// The uses-permission declarations the enabled toggles add, with their optional maxSdkVersion. + /// + internal IEnumerable<(string Permission, int? MaxSdkVersion)> GetPermissions() + { + if (Camera) yield return ("android.permission.CAMERA", null); + if (RecordAudio) yield return ("android.permission.RECORD_AUDIO", null); + if (AccessFineLocation) yield return ("android.permission.ACCESS_FINE_LOCATION", null); + if (AccessFineLocation || AccessCoarseLocation) yield return ("android.permission.ACCESS_COARSE_LOCATION", null); + if (AccessBackgroundLocation) yield return ("android.permission.ACCESS_BACKGROUND_LOCATION", null); + if (ReadMediaImages) + { + yield return ("android.permission.READ_MEDIA_IMAGES", null); + yield return ("android.permission.READ_MEDIA_VISUAL_USER_SELECTED", null); + yield return ("android.permission.READ_EXTERNAL_STORAGE", 32); + } + if (WriteExternalStorageLegacy) yield return ("android.permission.WRITE_EXTERNAL_STORAGE", 28); + if (PostNotifications) yield return ("android.permission.POST_NOTIFICATIONS", null); + if (Vibrate) yield return ("android.permission.VIBRATE", null); + } } /// One Android browsable deep-link registration. @@ -109,6 +230,18 @@ public sealed class AndroidDeepLinkRegistration /// Optional path-prefix constraint. [SerializeField] public string PathPrefix; + + /// + /// Marks the filter android:autoVerify="true" so Android verifies the https link against the domain's + /// assetlinks.json and opens it without a chooser. Needs an http or https scheme and a host. + /// + [SerializeField] public bool AutoVerify; + + /// True when the scheme is http or https and a host is set, the shape Android can verify. + internal bool IsVerifiableAppLink() => + !string.IsNullOrWhiteSpace(Host) && + (string.Equals(Scheme?.Trim(), "https", StringComparison.OrdinalIgnoreCase) || + string.Equals(Scheme?.Trim(), "http", StringComparison.OrdinalIgnoreCase)); } /// Native deep-link declarations shared by production builds and temporary sample overlays. @@ -141,14 +274,15 @@ public void AddIosUrlScheme(string scheme) } /// Adds an Android registration using semantic set semantics. - public void AddAndroidIntentFilter(string scheme, string host = null, string pathPrefix = null) + public void AddAndroidIntentFilter(string scheme, string host = null, string pathPrefix = null, bool autoVerify = false) { AndroidIntentFilters ??= new List(); var registration = new AndroidDeepLinkRegistration { Scheme = Normalize(scheme), Host = Normalize(host), - PathPrefix = Normalize(pathPrefix) + PathPrefix = Normalize(pathPrefix), + AutoVerify = autoVerify }; if (string.IsNullOrEmpty(registration.Scheme)) return; @@ -159,6 +293,7 @@ public void AddAndroidIntentFilter(string scheme, string host = null, string pat string.Equals(existing.Host?.Trim(), registration.Host, StringComparison.OrdinalIgnoreCase) && string.Equals(existing.PathPrefix?.Trim(), registration.PathPrefix, StringComparison.Ordinal)) { + existing.AutoVerify |= registration.AutoVerify; return; } } @@ -205,6 +340,10 @@ internal void CollectValidationErrors(string prefix, List errors) { errors.Add($"{prefix}.AndroidIntentFilters[{i}].Host is invalid: '{entry.Host}'."); } + if (entry.AutoVerify && !entry.IsVerifiableAppLink()) + { + errors.Add($"{prefix}.AndroidIntentFilters[{i}].AutoVerify needs an http or https scheme and a host: '{entry.Scheme}://{entry.Host}'."); + } if (!string.IsNullOrWhiteSpace(entry.PathPrefix) && (!entry.PathPrefix.Trim().StartsWith("/", StringComparison.Ordinal) || entry.PathPrefix.IndexOfAny(new[] { '?', '#' }) >= 0)) { @@ -233,7 +372,7 @@ private static bool ContainsIllegalHostCharacters(string host) /// UX (including the per-locale usage-description lists), and a single instance is located ANYWHERE /// in the project via / . Open it from /// Tools > GameLovers > Mobile Services > Select Mobile Services Config. - /// See docs/build-pipeline.md for the schema and behaviour. + /// See docs/build-configuration.md for the schema and behaviour. /// /// /// The type lives in the Editor assembly, so the asset is editor-only (keep it under an @@ -249,6 +388,9 @@ public sealed class MobileServicesConfig : ScriptableObject /// Default Maven coordinate for the Play In-App Review library injected on Android builds. public const string DefaultPlayReviewCoordinate = "com.google.android.play:review:2.0.2"; + /// Default Maven coordinate for the Custom Tabs library behind the Android in-app browser. + public const string DefaultCustomTabsCoordinate = "androidx.browser:browser:1.8.0"; + private static MobileServicesConfig _cached; private static bool _cachedIsTransient; @@ -257,8 +399,13 @@ public sealed class MobileServicesConfig : ScriptableObject [SerializeField] private CapabilityToggles _capabilities = new CapabilityToggles(); [SerializeField] private AndroidManifestToggles _androidManifest = new AndroidManifestToggles(); [SerializeField] private NativeDeepLinkSettings _deepLinks = new NativeDeepLinkSettings(); + [SerializeField] private GamePlatformSettings _gamePlatform = new GamePlatformSettings(); + [Tooltip("Logs Editor haptics play and stop calls to the Unity console. Disabled by default.")] + [SerializeField] private bool _enableHapticsDebugLogs; [SerializeField] private bool _includePlayReviewDependency = true; [SerializeField] private string _playReviewDependencyCoordinate = DefaultPlayReviewCoordinate; + [SerializeField] private bool _includeCustomTabsDependency = true; + [SerializeField] private string _customTabsDependencyCoordinate = DefaultCustomTabsCoordinate; [SerializeField] private bool _manageNativeBuildManually; private void OnEnable() @@ -270,9 +417,17 @@ private void OnEnable() _capabilities.AssociatedDomainList ??= new List(); _androidManifest ??= new AndroidManifestToggles(); _deepLinks ??= new NativeDeepLinkSettings(); + _gamePlatform ??= new GamePlatformSettings(); _deepLinks.IosUrlSchemes ??= new List(); _deepLinks.AndroidIntentFilters ??= new List(); _playReviewDependencyCoordinate ??= DefaultPlayReviewCoordinate; + _customTabsDependencyCoordinate ??= DefaultCustomTabsCoordinate; + ApplyEditorRuntimeSettings(); + } + + private void OnValidate() + { + ApplyEditorRuntimeSettings(); } /// @@ -329,6 +484,21 @@ public static MobileServicesConfig Instance /// iOS URL scheme and Android intent-filter declarations applied at build time. public NativeDeepLinkSettings DeepLinks => _deepLinks ??= new NativeDeepLinkSettings(); + /// Game Center and Google Play Games settings applied at build time. + public GamePlatformSettings GamePlatform => _gamePlatform ??= new GamePlatformSettings(); + + /// Whether Editor haptics calls write diagnostic messages to the Unity console. + public bool EnableHapticsDebugLogs + { + get => _enableHapticsDebugLogs; + set + { + _enableHapticsDebugLogs = value; + ApplyEditorRuntimeSettings(); + Persist(); + } + } + /// /// When true (the default), the Android build postprocessor auto-injects the Play In-App /// Review dependency () into the generated Gradle @@ -365,6 +535,37 @@ public string PlayReviewDependencyCoordinate } } + /// + /// When true (the default), the Android build postprocessor injects the androidx.browser library so + /// NativeUiService.OpenInAppBrowser can show Chrome Custom Tabs. It is skipped when the library is already + /// declared. Turn it off to keep the library out; the browser call then opens the external browser. + /// + public bool IncludeCustomTabsDependency + { + get => _includeCustomTabsDependency; + set + { + _includeCustomTabsDependency = value; + Persist(); + } + } + + /// + /// The full group:artifact:version Maven coordinate injected for Custom Tabs, for pinning a version or + /// using an internal mirror. Falls back to when blank. + /// + public string CustomTabsDependencyCoordinate + { + get => string.IsNullOrWhiteSpace(_customTabsDependencyCoordinate) + ? DefaultCustomTabsCoordinate + : _customTabsDependencyCoordinate; + set + { + _customTabsDependencyCoordinate = value; + Persist(); + } + } + /// /// When true, the package performs NO native build configuration — it writes nothing to /// the iOS Info.plist / entitlements / capabilities or the Android manifest / gradle, and skips @@ -440,6 +641,7 @@ internal static MobileServicesConfig CreateNeutralTransient() { var config = CreateInstance(); config._includePlayReviewDependency = false; + config._includeCustomTabsDependency = false; config.hideFlags = HideFlags.HideAndDontSave; return config; } @@ -499,7 +701,13 @@ internal void CollectValidationErrors(List errors) errors.Add("Capabilities.AssociatedDomains is enabled but AssociatedDomainList is empty."); } _deepLinks?.CollectValidationErrors(nameof(DeepLinks), errors); - if (_includePlayReviewDependency && !IsValidPlayReviewCoordinate(PlayReviewDependencyCoordinate)) + if (_gamePlatform == null) errors.Add("GamePlatform is null."); + _gamePlatform?.CollectValidationErrors(nameof(GamePlatform), errors); + if (_includeCustomTabsDependency && !IsValidMavenCoordinate(CustomTabsDependencyCoordinate)) + { + errors.Add($"CustomTabsDependencyCoordinate is invalid: '{CustomTabsDependencyCoordinate}'."); + } + if (_includePlayReviewDependency && !IsValidMavenCoordinate(PlayReviewDependencyCoordinate)) { errors.Add($"PlayReviewDependencyCoordinate is invalid: '{PlayReviewDependencyCoordinate}'."); } @@ -514,7 +722,8 @@ public bool TryValidate(out string error) return errors.Count == 0; } - private static bool IsValidPlayReviewCoordinate(string coordinate) + /// True for a group:artifact:version coordinate whose three parts are all non-empty. + internal static bool IsValidMavenCoordinate(string coordinate) { if (string.IsNullOrWhiteSpace(coordinate)) return false; var pieces = coordinate.Split(':'); @@ -736,6 +945,11 @@ private static MobileServicesConfig FindAsset() return TryGetPersistedConfig(out var config) ? config : null; } + private void ApplyEditorRuntimeSettings() + { + EditorHapticsBackend.DebugLoggingEnabled = _enableHapticsDebugLogs; + } + private static void CollectLocales(List entries, HashSet set) { foreach (var entry in entries ?? new List()) @@ -772,4 +986,19 @@ private static void SetLocaleEntry(List entries, string locale, str entries.Add(new LocaleEntry { LocaleCode = locale, UsageDescription = text }); } } + + /// Restores Mobile Services Editor runtime settings after Unity reloads the package assemblies. + [InitializeOnLoad] + internal static class MobileServicesConfigInitializer + { + static MobileServicesConfigInitializer() + { + EditorApplication.delayCall += Apply; + } + + private static void Apply() + { + EditorHapticsBackend.DebugLoggingEnabled = MobileServicesConfig.Instance.EnableHapticsDebugLogs; + } + } } diff --git a/Editor/Settings/MobileServicesConfigEditor.cs b/Editor/Settings/MobileServicesConfigEditor.cs index e0da7f1..c7c7fd2 100644 --- a/Editor/Settings/MobileServicesConfigEditor.cs +++ b/Editor/Settings/MobileServicesConfigEditor.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text; using GameLovers.MobileServices.Device; +using GameLovers.MobileServices.Editor.NativeBuild; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; @@ -24,6 +27,8 @@ namespace GameLovers.MobileServices.Editor.Settings [CustomEditor(typeof(MobileServicesConfig))] internal sealed class MobileServicesConfigEditor : UnityEditor.Editor { + private const string AndroidPermissionPrefix = "android.permission."; + private HelpBox _statusBox; /// @@ -44,9 +49,12 @@ public override VisualElement CreateInspectorGUI() _statusBox = new HelpBox(string.Empty, HelpBoxMessageType.Info); root.Add(_statusBox); - root.Add(MakeSection("Usage descriptions (Info.plist) — localized per device language")); + var summaries = new List<(Label Label, Func> Lines)>(); + + root.Add(MakeSection("Permissions and privacy")); var locHint = new Label("Each permission holds one entry per locale (ISO code: en, fr, pt-BR …). " + - "The 'en' value is written to Info.plist; other locales are emitted as .lproj/InfoPlist.strings at build."); + "The 'en' value is written to Info.plist; other locales are emitted as .lproj/InfoPlist.strings at build. " + + "Writing a usage description is what switches that iOS permission on."); locHint.style.whiteSpace = WhiteSpace.Normal; locHint.style.fontSize = 10; locHint.style.unityFontStyleAndWeight = FontStyle.Italic; @@ -54,34 +62,107 @@ public override VisualElement CreateInspectorGUI() root.Add(locHint); root.Add(new PropertyField(serializedObject.FindProperty("_permissionDescriptions"), "Permission Descriptions")); root.Add(new PropertyField(serializedObject.FindProperty("_attUsageDescription"), "App Tracking (ATT) Description")); + AddRelative(root, "_capabilities", "AppTracking"); + AddRelative(root, "_androidManifest", "Camera", "RecordAudio", "AccessFineLocation", "AccessCoarseLocation", + "AccessBackgroundLocation", "ReadMediaImages"); + AddSummary(root, summaries, () => DescribePermissions(config)); - root.Add(MakeSection("Capabilities")); - root.Add(new PropertyField(serializedObject.FindProperty("_capabilities"), "Capabilities")); - - root.Add(MakeSection("Android manifest")); - root.Add(new PropertyField(serializedObject.FindProperty("_androidManifest"), "Android Manifest")); + root.Add(MakeSection("Notifications")); + AddRelative(root, "_capabilities", "PushNotifications"); + AddRelative(root, "_androidManifest", "PostNotifications", "Vibrate"); + AddSummary(root, summaries, () => DescribeNotifications(config)); - root.Add(MakeSection("Native deep links")); + root.Add(MakeSection("Deep links")); + AddRelative(root, "_capabilities", "AssociatedDomains", "AssociatedDomainList"); root.Add(new PropertyField(serializedObject.FindProperty("_deepLinks"), "Deep-link registrations")); + AddSummary(root, summaries, () => DescribeDeepLinks(config)); + + root.Add(MakeSection("Sharing")); + AddRelative(root, "_androidManifest", "IncludeShareQueriesBlock", "IncludeShareFileProvider"); + AddSummary(root, summaries, () => DescribeSharing(config)); + + root.Add(MakeSection("Media (gallery and picker)")); + AddRelative(root, "_androidManifest", "IncludeMediaPicker", "WriteExternalStorageLegacy"); + AddSummary(root, summaries, () => DescribeMedia(config)); - root.Add(MakeSection("Android dependencies")); + root.Add(MakeSection("Game platform (Game Center and Play Games)")); + var gamePlatformHint = new Label("Opt-in. Game Center needs the capability on your App ID and provisioning profile. " + + "Entering the Play Games project id adds the Play Games Services SDK to Android builds."); + gamePlatformHint.style.whiteSpace = WhiteSpace.Normal; + gamePlatformHint.style.fontSize = 10; + gamePlatformHint.style.unityFontStyleAndWeight = FontStyle.Italic; + root.Add(gamePlatformHint); + AddRelative(root, "_gamePlatform", "GameCenter", "PlayGamesProjectId", "PlayGamesDependencyCoordinate", "CloudSave"); + AddSummary(root, summaries, () => DescribeGamePlatform(config)); + + root.Add(MakeSection("In-app browser")); + root.Add(new PropertyField(serializedObject.FindProperty("_includeCustomTabsDependency"), "Include Custom Tabs Dependency")); + root.Add(new PropertyField(serializedObject.FindProperty("_customTabsDependencyCoordinate"), "Custom Tabs Coordinate")); + AddSummary(root, summaries, () => DescribeInAppBrowser(config)); + + root.Add(MakeSection("Store review")); root.Add(new PropertyField(serializedObject.FindProperty("_includePlayReviewDependency"), "Include Play Review Dependency")); root.Add(new PropertyField(serializedObject.FindProperty("_playReviewDependencyCoordinate"), "Play Review Coordinate")); + AddSummary(root, summaries, () => DescribeStoreReview(config)); root.Add(MakeSection("Build behaviour")); root.Add(new PropertyField(serializedObject.FindProperty("_manageNativeBuildManually"), "Manage Native Build Manually")); + root.Add(MakeSection("Editor diagnostics")); + root.Add(new PropertyField(serializedObject.FindProperty("_enableHapticsDebugLogs"), "Enable Haptics Debug Logs")); + root.Add(MakeSection("Tools")); root.Add(BuildScanButton(config)); root.Add(BuildSuggestCopyButton(config)); root.Add(BuildPrivacyNutritionButton(config)); root.Bind(serializedObject); - root.TrackSerializedObjectValue(serializedObject, _ => UpdateStatus(config)); + root.TrackSerializedObjectValue(serializedObject, _ => + { + UpdateStatus(config); + RefreshSummaries(config, summaries); + }); UpdateStatus(config); + RefreshSummaries(config, summaries); return root; } + private void AddRelative(VisualElement root, string structName, params string[] fieldNames) + { + var parent = serializedObject.FindProperty(structName); + foreach (var fieldName in fieldNames) + { + root.Add(new PropertyField(parent.FindPropertyRelative(fieldName))); + } + } + + private static void AddSummary(VisualElement root, List<(Label Label, Func> Lines)> summaries, Func> lines) + { + var label = new Label(); + label.style.whiteSpace = WhiteSpace.Normal; + label.style.fontSize = 10; + label.style.unityFontStyleAndWeight = FontStyle.Italic; + label.style.marginTop = 2; + root.Add(label); + summaries.Add((label, lines)); + } + + private static void RefreshSummaries(MobileServicesConfig config, List<(Label Label, Func> Lines)> summaries) + { + foreach (var (label, lines) in summaries) + { + if (config.ManageNativeBuildManually) + { + label.text = "Adds to build: nothing. Manage Native Build Manually is on."; + continue; + } + var entries = lines(); + label.text = entries.Count == 0 + ? "Adds to build: nothing." + : "Adds to build:\n- " + string.Join("\n- ", entries); + } + } + private static Label MakeSection(string text) { var lbl = new Label(text); @@ -129,6 +210,116 @@ private static int CountMissingDescriptions(MobileServicesConfig config) return count; } + private static IReadOnlyList DescribePermissions(MobileServicesConfig config) + { + var lines = new List(); + var plistKeys = new List(); + foreach (var row in config.PermissionDescriptions) + { + var key = row == null ? null : MobileServicesConfig.GetIosUsageKey(row.Permission); + if (key != null && !string.IsNullOrWhiteSpace(config.GetUsageDescriptionEn(row.Permission))) plistKeys.Add(key); + } + if (config.Capabilities.AppTracking && !string.IsNullOrWhiteSpace(config.GetAttUsageDescriptionEn())) + { + plistKeys.Add("NSUserTrackingUsageDescription"); + } + if (plistKeys.Count > 0) lines.Add("iOS Info.plist: " + string.Join(", ", plistKeys.Distinct())); + + var groups = IosPermissionGroups.All + .Where(group => group.PlistKeys.Any(plistKeys.Contains)) + .Select(group => group.Name) + .ToList(); + lines.Add(groups.Count == 0 + ? "iOS permission code compiled in: none from this config (keys already in Player Settings or Info.plist also count)" + : "iOS permission code compiled in: " + string.Join(", ", groups) + " (undeclared groups stay out of the binary)"); + + AddAndroidPermissions(lines, config.AndroidManifest, permission => permission != "POST_NOTIFICATIONS" && permission != "VIBRATE" && permission != "WRITE_EXTERNAL_STORAGE"); + return lines; + } + + private static IReadOnlyList DescribeMedia(MobileServicesConfig config) + { + var lines = new List(); + if (config.PermissionDescriptions.Any(row => row != null && row.Permission == AppPermission.PhotoLibraryAddOnly && + !string.IsNullOrWhiteSpace(config.GetUsageDescriptionEn(AppPermission.PhotoLibraryAddOnly)))) + { + lines.Add("iOS: gallery saving compiled in (NSPhotoLibraryAddUsageDescription is declared)"); + } + if (config.AndroidManifest.IncludeMediaPicker) lines.Add("Android manifest: GameLoversMediaPickerActivity (image picker host)"); + AddAndroidPermissions(lines, config.AndroidManifest, permission => permission == "WRITE_EXTERNAL_STORAGE"); + return lines; + } + + private static IReadOnlyList DescribeNotifications(MobileServicesConfig config) + { + var lines = new List(); + if (config.Capabilities.PushNotifications) lines.Add("iOS entitlement: Push Notifications (aps-environment)"); + AddAndroidPermissions(lines, config.AndroidManifest, permission => permission == "POST_NOTIFICATIONS" || permission == "VIBRATE"); + return lines; + } + + private static IReadOnlyList DescribeDeepLinks(MobileServicesConfig config) + { + var lines = new List(); + var iosSchemes = config.DeepLinks.IosUrlSchemes.Where(scheme => !string.IsNullOrWhiteSpace(scheme)).ToList(); + if (iosSchemes.Count > 0) lines.Add("iOS Info.plist URL schemes: " + string.Join(", ", iosSchemes)); + if (config.Capabilities.AssociatedDomains && config.Capabilities.AssociatedDomainList.Count > 0) + { + lines.Add("iOS entitlement: Associated Domains (" + string.Join(", ", config.Capabilities.AssociatedDomainList) + ")"); + } + foreach (var filter in config.DeepLinks.AndroidIntentFilters.Where(filter => filter != null)) + { + lines.Add($"Android intent filter: {filter.Scheme}://{filter.Host}{filter.PathPrefix}{(filter.AutoVerify ? " (autoVerify)" : string.Empty)}"); + } + return lines; + } + + private static IReadOnlyList DescribeSharing(MobileServicesConfig config) + { + var lines = new List(); + if (config.AndroidManifest.IncludeShareQueriesBlock) lines.Add("Android manifest: entry for ACTION_SEND share targets"); + if (config.AndroidManifest.IncludeShareFileProvider) lines.Add("Android manifest: GameLoversFileProvider (images can be shared on Android 7+) and res/xml/gamelovers_file_paths.xml"); + return lines; + } + + private static IReadOnlyList DescribeGamePlatform(MobileServicesConfig config) + { + var lines = new List(); + var settings = config.GamePlatform; + if (settings.GameCenter) lines.Add("iOS: Game Center entitlement and the GameKit bridge compiled in"); + if (settings.CloudSave) lines.Add("iOS entitlement: iCloud Documents with the default container (Game Center saved games)"); + if (settings.PlayGamesEnabled) + { + lines.Add($"Android Gradle: implementation '{settings.ResolvePlayGamesCoordinate()}'"); + lines.Add("Android manifest: com.google.android.gms.games.APP_ID meta-data and res/values/gamelovers_play_games_project_id.xml"); + } + return lines; + } + + private static IReadOnlyList DescribeStoreReview(MobileServicesConfig config) + { + var lines = new List(); + if (config.IncludePlayReviewDependency) lines.Add($"Android Gradle: implementation '{config.PlayReviewDependencyCoordinate}'"); + return lines; + } + + private static IReadOnlyList DescribeInAppBrowser(MobileServicesConfig config) + { + var lines = new List(); + if (config.IncludeCustomTabsDependency) lines.Add($"Android Gradle: implementation '{config.CustomTabsDependencyCoordinate}'"); + return lines; + } + + private static void AddAndroidPermissions(List lines, AndroidManifestToggles toggles, Func filter) + { + var permissions = toggles.GetPermissions() + .Select(entry => (Name: entry.Permission.StartsWith(AndroidPermissionPrefix, StringComparison.Ordinal) ? entry.Permission.Substring(AndroidPermissionPrefix.Length) : entry.Permission, entry.MaxSdkVersion)) + .Where(entry => filter(entry.Name)) + .Select(entry => entry.MaxSdkVersion.HasValue ? $"{entry.Name} (up to API {entry.MaxSdkVersion.Value})" : entry.Name) + .ToList(); + if (permissions.Count > 0) lines.Add("Android manifest: " + string.Join(", ", permissions)); + } + private Button BuildScanButton(MobileServicesConfig config) { var btn = new Button(() => diff --git a/Editor/Settings/MobileServicesScanner.cs b/Editor/Settings/MobileServicesScanner.cs index 2b78788..171a46e 100644 --- a/Editor/Settings/MobileServicesScanner.cs +++ b/Editor/Settings/MobileServicesScanner.cs @@ -15,6 +15,8 @@ public sealed class ProjectScanResult public bool UsesNotifications; public bool UsesDeepLinks; public bool UsesNativeUiShare; + public bool UsesGamePlatform; + public bool UsesCloudSave; /// Returns advisory mismatches without changing the persisted config. public IReadOnlyList GetConfigurationWarnings(MobileServicesConfig config) @@ -33,10 +35,22 @@ public IReadOnlyList GetConfigurationWarnings(MobileServicesConfig confi { warnings.Add("Deep-link services are referenced but no native deep-link scheme or Android intent-filter is configured."); } + if (UsesGamePlatform && !config.GamePlatform.GameCenter && !config.GamePlatform.PlayGamesEnabled) + { + warnings.Add("Game platform services are referenced but neither Game Center nor a Play Games project id is configured; every call fails at runtime."); + } + if (UsesCloudSave && !config.GamePlatform.CloudSave) + { + warnings.Add("Cloud saves are referenced but the Cloud Save toggle is disabled; iOS saved games need the iCloud capability and fail with CloudUnavailable without it."); + } if (UsesNativeUiShare && !config.AndroidManifest.IncludeShareQueriesBlock) { warnings.Add("Native UI sharing is referenced but Android share-package queries are disabled."); } + if (UsesNativeUiShare && !config.AndroidManifest.IncludeShareFileProvider) + { + warnings.Add("Native UI sharing is referenced but the Android share FileProvider is disabled; sharing an image on Android 7+ shares only the text."); + } foreach (var permission in ReferencedPermissions) { if (MobileServicesConfig.GetIosUsageKey(permission) != null && string.IsNullOrWhiteSpace(config.GetUsageDescriptionEn(permission))) @@ -70,6 +84,9 @@ public static ProjectScanResult Scan() var attType = typeof(AttService); var attInterface = typeof(IAttService); var nativeUiType = typeof(MobileServices.NativeUi.NativeUiService); + var gamePlatformType = typeof(MobileServices.GamePlatform.GamePlatformServices); + var gamePlatformInterface = typeof(MobileServices.GamePlatform.IGamePlatformServices); + var cloudSaveInterface = typeof(MobileServices.GamePlatform.ICloudSaveService); // Walk every user assembly. Built-in / package assemblies are skipped to keep the scan fast. var assemblies = System.AppDomain.CurrentDomain.GetAssemblies(); @@ -114,6 +131,9 @@ public static ProjectScanResult Scan() } } if (TypeReferences(type, nativeUiType)) result.UsesNativeUiShare = true; + if (TypeReferences(type, cloudSaveInterface)) result.UsesCloudSave = true; + if (TypeReferences(type, gamePlatformType) || + TypeReferences(type, gamePlatformInterface)) result.UsesGamePlatform = true; } } diff --git a/Editor/Simulation/EditorPlatformSimulator.cs b/Editor/Simulation/EditorPlatformSimulator.cs index 59b1871..18f81fb 100644 --- a/Editor/Simulation/EditorPlatformSimulator.cs +++ b/Editor/Simulation/EditorPlatformSimulator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using GameLovers.MobileServices.Device; using GameLovers.MobileServices.Editor.Explorer.Overlays; @@ -12,9 +13,9 @@ namespace GameLovers.MobileServices.Editor.Simulation { /// - /// Editor-only façade for driving platform state (battery, connectivity, safe area, deep links, + /// Editor-only façade for driving platform state (battery, connectivity, deep links, /// permissions, ATT) from edit-mode tests and the Device Simulator panel. See - /// docs/explorer.md for the comparison with Unity's Device Simulator. + /// docs/device-simulator.md for the comparison with Unity's Device Simulator. /// public static class EditorPlatformSimulator { @@ -58,36 +59,6 @@ public static void SetIosLowPowerMode(bool enabled, params BatteryService[] serv } } - /// - /// 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 @@ -134,6 +105,9 @@ public static void Engage() _engaged = true; PermissionsService.EditorCheckOverride = ReadPermissionStore; PermissionsService.EditorRequestAsyncOverride = RequestPermissionAsync; + DeviceService.EditorOpenAppSettingsOverride = OpenAppSettings; + NativeMediaService.EditorSaveToGalleryOverride = SaveToSimulatedGallery; + NativeMediaService.EditorPickImageOverride = PickImageFromDisk; AttService.EditorCurrentStatusOverride = ReadAttStore(); AttService.EditorRequestAsyncOverride = RequestAttAsync; NativeUiService.EditorShowAlertOverride = MobileSimulatorRuntimeOverlay.ShowAlert; @@ -154,6 +128,9 @@ public static void Disengage() PermissionsService.EditorCheckOverride = null; PermissionsService.EditorRequestOverride = null; PermissionsService.EditorRequestAsyncOverride = null; + DeviceService.EditorOpenAppSettingsOverride = null; + NativeMediaService.EditorSaveToGalleryOverride = null; + NativeMediaService.EditorPickImageOverride = null; AttService.EditorCurrentStatusOverride = null; AttService.EditorRequestResultOverride = null; AttService.EditorRequestAsyncOverride = null; @@ -233,6 +210,34 @@ public static void ResolvePendingAttPrompt(bool allow) /// Closes any active simulator-overlay dialog without firing a button callback. public static void DismissAllOverlays() => MobileSimulatorState.PushDismissAll(); + private static bool SaveToSimulatedGallery(string filePath, MediaType mediaType) + { + var folder = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Library", "GameLovers", "SimulatedGallery"); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, $"{DateTime.Now:yyyyMMdd-HHmmss-fff}-{Path.GetFileName(filePath)}"); + File.Copy(filePath, destination); + Debug.Log($"[GameLovers.MobileServices] Simulator: saved the {mediaType.ToString().ToLowerInvariant()} to {destination}"); + return true; + } + + // Like the real pickers, hands back a private copy so the caller can delete it without touching the original. + private static string PickImageFromDisk() + { + var chosen = EditorUtility.OpenFilePanel("Simulator: pick an image", string.Empty, "png,jpg,jpeg,gif,webp"); + if (string.IsNullOrEmpty(chosen)) + { + return null; + } + + var copy = Path.Combine(Application.temporaryCachePath, $"gamelovers-picked-{Guid.NewGuid():N}{Path.GetExtension(chosen)}"); + File.Copy(chosen, copy); + return copy; + } + + private static void OpenAppSettings() => + Debug.Log("[GameLovers.MobileServices] Simulator: DeviceService.OpenAppSettings() would open the OS Settings page. " + + "Change the simulated permission state in the Device Simulator panel's Permissions section instead."); + private static PermissionStatus ReadPermissionStore(AppPermission permission) => (PermissionStatus)EditorPrefs.GetInt(PermStorePrefix + permission, (int)PermissionStatus.NotDetermined); diff --git a/Editor/Simulation/MobileNotificationSimulation.cs b/Editor/Simulation/MobileNotificationSimulation.cs index ebbf4e3..c354ba0 100644 --- a/Editor/Simulation/MobileNotificationSimulation.cs +++ b/Editor/Simulation/MobileNotificationSimulation.cs @@ -91,6 +91,18 @@ public static void Tick() } } + /// Reports a delivered notification as opened, as if the player tapped its banner. + public static bool TryOpen(INotificationService service, IGameNotification notification) + { + if (!(service is MobileNotificationService mobileService) || notification == null) + { + return false; + } + + mobileService.SimulateOpen(notification); + return true; + } + /// Invokes the runtime service's explicit editor delivery path. public static bool TryDeliver(INotificationService service, int notificationId) { diff --git a/Plugins/Android.meta b/Plugins/Android.meta new file mode 100644 index 0000000..2582348 --- /dev/null +++ b/Plugins/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5df49728e23944fb39a14b49086578e0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/Android/GameLoversFileProvider.java b/Plugins/Android/GameLoversFileProvider.java new file mode 100644 index 0000000..838cc47 --- /dev/null +++ b/Plugins/Android/GameLoversFileProvider.java @@ -0,0 +1,10 @@ +package com.gamelovers.mobileservices; + +import androidx.core.content.FileProvider; + +/** + * Package-owned FileProvider subclass. A unique class name lets the manifest declare it next to any FileProvider + * that the game or another SDK already registers, without a manifest merge conflict. + */ +public class GameLoversFileProvider extends FileProvider { +} diff --git a/Plugins/Android/GameLoversFileProvider.java.meta b/Plugins/Android/GameLoversFileProvider.java.meta new file mode 100644 index 0000000..62f4a61 --- /dev/null +++ b/Plugins/Android/GameLoversFileProvider.java.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc6acebc9a78047a5930011081fac14e \ No newline at end of file diff --git a/Plugins/Android/GameLoversMedia.java b/Plugins/Android/GameLoversMedia.java new file mode 100644 index 0000000..4143d7f --- /dev/null +++ b/Plugins/Android/GameLoversMedia.java @@ -0,0 +1,127 @@ +package com.gamelovers.mobileservices; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** Saves a file to the shared gallery without any storage permission on Android 10 and newer. */ +public final class GameLoversMedia { + /** Receives the outcome on a background thread: a non-null value means success, null means failure. */ + public interface ResultCallback { + void onResult(String result); + } + + private GameLoversMedia() { + } + + /** Copies {@code path} into the shared Pictures or Movies collection under {@code album} on a worker thread. */ + public static void saveToGallery(final Activity activity, final String path, final boolean video, + final String album, final ResultCallback callback) { + new Thread(new Runnable() { + @Override + public void run() { + String result = null; + try { + result = save(activity, new File(path), video, album); + } catch (Exception e) { + android.util.Log.w("GameLovers", "SaveToGallery failed: " + e.getMessage()); + } + callback.onResult(result); + } + }, "GameLovers-SaveToGallery").start(); + } + + private static String save(Activity activity, File source, boolean video, String album) throws IOException { + if (!source.isFile()) { + return null; + } + + String mimeType = guessMimeType(source.getName(), video); + String directory = video ? Environment.DIRECTORY_MOVIES : Environment.DIRECTORY_PICTURES; + + if (Build.VERSION.SDK_INT >= 29) { + ContentResolver resolver = activity.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, source.getName()); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, directory + "/" + album); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + Uri collection = video + ? MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + : MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + Uri item = resolver.insert(collection, values); + if (item == null) { + return null; + } + + try (InputStream in = new FileInputStream(source); OutputStream out = resolver.openOutputStream(item)) { + if (out == null) { + resolver.delete(item, null, null); + return null; + } + copy(in, out); + } catch (IOException e) { + resolver.delete(item, null, null); + throw e; + } + + values.clear(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(item, values, null, null); + return item.toString(); + } + + File folder = new File(Environment.getExternalStoragePublicDirectory(directory), album); + if (!folder.isDirectory() && !folder.mkdirs()) { + return null; + } + + File destination = uniqueFile(folder, source.getName()); + try (InputStream in = new FileInputStream(source); OutputStream out = new FileOutputStream(destination)) { + copy(in, out); + } + MediaScannerConnection.scanFile(activity, new String[]{destination.getAbsolutePath()}, new String[]{mimeType}, null); + return destination.getAbsolutePath(); + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + out.flush(); + } + + private static File uniqueFile(File folder, String name) { + File candidate = new File(folder, name); + int dot = name.lastIndexOf('.'); + String stem = dot > 0 ? name.substring(0, dot) : name; + String extension = dot > 0 ? name.substring(dot) : ""; + for (int i = 1; candidate.exists(); i++) { + candidate = new File(folder, stem + "-" + i + extension); + } + return candidate; + } + + private static String guessMimeType(String fileName, boolean video) { + int dot = fileName.lastIndexOf('.'); + String extension = dot >= 0 ? fileName.substring(dot + 1).toLowerCase() : ""; + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + return mimeType != null ? mimeType : (video ? "video/mp4" : "image/png"); + } +} diff --git a/Plugins/Android/GameLoversMedia.java.meta b/Plugins/Android/GameLoversMedia.java.meta new file mode 100644 index 0000000..d836346 --- /dev/null +++ b/Plugins/Android/GameLoversMedia.java.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a06894afffda34f4d8ceaa3e1953ab28 \ No newline at end of file diff --git a/Plugins/Android/GameLoversMediaPickerActivity.java b/Plugins/Android/GameLoversMediaPickerActivity.java new file mode 100644 index 0000000..7c401e4 --- /dev/null +++ b/Plugins/Android/GameLoversMediaPickerActivity.java @@ -0,0 +1,129 @@ +package com.gamelovers.mobileservices; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; + +/** + * Transparent host for the system image picker. UnityPlayerActivity cannot receive activity results, so this + * activity launches the picker, copies the chosen image into the app cache, reports the path, and finishes. + * Needs no permission: the Android 13 photo picker and the document picker grant access to the chosen item only. + */ +public class GameLoversMediaPickerActivity extends Activity { + /** Receives the copied file path on a background thread, or null when the player cancelled or the copy failed. */ + public interface PathCallback { + void onPicked(String path); + } + + private static final int RequestPick = 7141; + private static final Object Lock = new Object(); + private static PathCallback pending; + + /** Starts the picker; reports null immediately when another pick is still open. */ + public static void start(Activity host, PathCallback callback) { + synchronized (Lock) { + if (pending != null) { + callback.onPicked(null); + return; + } + pending = callback; + } + host.startActivity(new Intent(host, GameLoversMediaPickerActivity.class)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + // Recreated after the process was killed: the original request is gone. + finish(); + return; + } + + Intent intent; + if (Build.VERSION.SDK_INT >= 33) { + intent = new Intent(MediaStore.ACTION_PICK_IMAGES); + } else { + intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + } + intent.setType("image/*"); + + try { + startActivityForResult(intent, RequestPick); + } catch (Exception e) { + complete(null); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != RequestPick) { + return; + } + + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + complete(null); + return; + } + + final Uri uri = data.getData(); + new Thread(new Runnable() { + @Override + public void run() { + complete(copyToCache(uri)); + } + }, "GameLovers-PickImage").start(); + } + + private String copyToCache(Uri uri) { + ContentResolver resolver = getContentResolver(); + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(resolver.getType(uri)); + File destination = new File(getCacheDir(), "gamelovers-picked-" + UUID.randomUUID() + "." + (extension != null ? extension : "img")); + try (InputStream in = resolver.openInputStream(uri); OutputStream out = new FileOutputStream(destination)) { + if (in == null) { + return null; + } + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return destination.getAbsolutePath(); + } catch (Exception e) { + android.util.Log.w("GameLovers", "PickImage failed to copy the selection: " + e.getMessage()); + return null; + } + } + + private void complete(final String path) { + PathCallback callback; + synchronized (Lock) { + callback = pending; + pending = null; + } + + final PathCallback target = callback; + runOnUiThread(new Runnable() { + @Override + public void run() { + finish(); + if (target != null) { + target.onPicked(path); + } + } + }); + } +} diff --git a/Plugins/Android/GameLoversMediaPickerActivity.java.meta b/Plugins/Android/GameLoversMediaPickerActivity.java.meta new file mode 100644 index 0000000..b97e8ab --- /dev/null +++ b/Plugins/Android/GameLoversMediaPickerActivity.java.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c93de3ecdd70846b895815a4ebaa1b01 \ No newline at end of file diff --git a/Plugins/Android/GameLoversShortcuts.java b/Plugins/Android/GameLoversShortcuts.java new file mode 100644 index 0000000..5e0ad9c --- /dev/null +++ b/Plugins/Android/GameLoversShortcuts.java @@ -0,0 +1,57 @@ +package com.gamelovers.mobileservices; + +import android.app.Activity; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.net.Uri; +import android.os.Build; + +import java.util.ArrayList; +import java.util.List; + +/** Publishes dynamic launcher shortcuts whose tap opens this app's activity with a deep-link URI. */ +public final class GameLoversShortcuts { + private GameLoversShortcuts() { + } + + /** Replaces the app's dynamic shortcuts. Arrays are parallel; an empty subtitle means none. */ + public static void set(Activity activity, String[] ids, String[] titles, String[] subtitles, String[] uris) { + if (Build.VERSION.SDK_INT < 25) { + return; + } + + ShortcutManager manager = activity.getSystemService(ShortcutManager.class); + if (manager == null) { + return; + } + + List shortcuts = new ArrayList<>(); + for (int i = 0; i < ids.length; i++) { + // An explicit component keeps the tap inside this app, and Unity reads the data URI as a deep link. + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uris[i])); + intent.setClassName(activity.getPackageName(), activity.getClass().getName()); + + ShortcutInfo.Builder builder = new ShortcutInfo.Builder(activity, ids[i]) + .setShortLabel(titles[i]) + .setIntent(intent); + if (subtitles[i] != null && !subtitles[i].isEmpty()) { + builder.setLongLabel(subtitles[i]); + } + shortcuts.add(builder.build()); + } + manager.setDynamicShortcuts(shortcuts); + } + + /** Removes every dynamic shortcut the app published. */ + public static void clear(Activity activity) { + if (Build.VERSION.SDK_INT < 25) { + return; + } + + ShortcutManager manager = activity.getSystemService(ShortcutManager.class); + if (manager != null) { + manager.removeAllDynamicShortcuts(); + } + } +} diff --git a/Plugins/Android/GameLoversShortcuts.java.meta b/Plugins/Android/GameLoversShortcuts.java.meta new file mode 100644 index 0000000..c25e912 --- /dev/null +++ b/Plugins/Android/GameLoversShortcuts.java.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd6289a64275b4eafb9aaee789c16571 \ No newline at end of file diff --git a/Plugins/iOS/AppShortcuts.m b/Plugins/iOS/AppShortcuts.m new file mode 100644 index 0000000..938091a --- /dev/null +++ b/Plugins/iOS/AppShortcuts.m @@ -0,0 +1,97 @@ +#import +#import +#import + +// A tapped shortcut waits here until managed code collects it: on a cold start UIKit delivers it before Unity's C# runs. +static NSString *gPendingShortcutUri = nil; +static IMP gOriginalPerformAction = NULL; + +typedef void (^GLShortcutCompletion)(BOOL); + +static void GLPerformActionForShortcut(id self, SEL _cmd, UIApplication *application, UIApplicationShortcutItem *item, GLShortcutCompletion completion) +{ + id uri = item.userInfo[@"uri"]; + if ([uri isKindOfClass:[NSString class]]) + { + @synchronized ([NSNull null]) + { + gPendingShortcutUri = uri; + } + } + + // An existing implementation (Firebase, another SDK, the game) keeps running and owns the completion call. + if (gOriginalPerformAction != NULL) + { + ((void (*)(id, SEL, UIApplication *, UIApplicationShortcutItem *, GLShortcutCompletion))gOriginalPerformAction)(self, _cmd, application, item, completion); + return; + } + + completion([uri isKindOfClass:[NSString class]]); +} + +@interface GLShortcutInstaller : NSObject +@end + +@implementation GLShortcutInstaller + +// Installed at load time because UIKit asks the delegate about a cold-start shortcut right after launch. ++ (void)load +{ + Class delegateClass = NSClassFromString(@"UnityAppController"); + if (delegateClass == Nil) + { + NSLog(@"[GameLovers.MobileServices] UnityAppController was not found; tapped shortcuts will not reach the game."); + return; + } + + SEL selector = @selector(application:performActionForShortcutItem:completionHandler:); + Method existing = class_getInstanceMethod(delegateClass, selector); + if (existing != NULL) + { + gOriginalPerformAction = method_setImplementation(existing, (IMP)GLPerformActionForShortcut); + return; + } + + class_addMethod(delegateClass, selector, (IMP)GLPerformActionForShortcut, "v@:@@@?"); +} + +@end + +void _GameLoversShortcutsSet(const char *json) +{ + NSData *data = [[NSString stringWithUTF8String:json] dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSMutableArray *items = [NSMutableArray array]; + for (NSDictionary *entry in root[@"items"]) + { + NSString *subtitle = entry[@"subtitle"]; + [items addObject:[[UIApplicationShortcutItem alloc] initWithType:entry[@"id"] + localizedTitle:entry[@"title"] + localizedSubtitle:subtitle.length > 0 ? subtitle : nil + icon:nil + userInfo:@{ @"uri": entry[@"uri"] }]]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [UIApplication sharedApplication].shortcutItems = items; + }); +} + +void _GameLoversShortcutsClear(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [UIApplication sharedApplication].shortcutItems = @[]; + }); +} + +// Returns the URI of a shortcut tapped since the last call as a copy the caller reads, or NULL. +const char *_GameLoversShortcutTakePending(void) +{ + NSString *uri = nil; + @synchronized ([NSNull null]) + { + uri = gPendingShortcutUri; + gPendingShortcutUri = nil; + } + return uri == nil ? NULL : strdup([uri UTF8String]); +} diff --git a/Plugins/iOS/AppShortcuts.m.meta b/Plugins/iOS/AppShortcuts.m.meta new file mode 100644 index 0000000..1d33760 --- /dev/null +++ b/Plugins/iOS/AppShortcuts.m.meta @@ -0,0 +1,26 @@ +fileFormatVersion: 2 +guid: 4e6a8c0b2d3f4a5c9e1b3d5f7a9c1e2f +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: {} + Editor: + enabled: 0 + settings: + DefaultValueInitialized: true + iOS: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/iOS/BackgroundTask.m b/Plugins/iOS/BackgroundTask.m new file mode 100644 index 0000000..b1b913f --- /dev/null +++ b/Plugins/iOS/BackgroundTask.m @@ -0,0 +1,122 @@ +#import +#import + +typedef void (*GLBackgroundTaskExpiredCallback)(int taskId); + +// Our task id -> UIBackgroundTaskIdentifier. A task leaves the map when it ends; its expired flag stays until the +// managed handle is disposed, so IsExpired can be polled from any thread after the system ended the task. +static NSMutableDictionary *gTasks = nil; +static NSMutableSet *gExpired = nil; +static int gNextTaskId = 1; + +static NSObject *GLTaskLock(void) +{ + static NSObject *lock = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + lock = [[NSObject alloc] init]; + gTasks = [NSMutableDictionary dictionary]; + gExpired = [NSMutableSet set]; + }); + return lock; +} + +static void GLRunOnMainSync(void (^block)(void)) +{ + if ([NSThread isMainThread]) + { + block(); + } + else + { + dispatch_sync(dispatch_get_main_queue(), block); + } +} + +static void GLEndNativeTask(int taskId) +{ + NSNumber *native = nil; + @synchronized (GLTaskLock()) + { + native = gTasks[@(taskId)]; + [gTasks removeObjectForKey:@(taskId)]; + } + + if (native != nil) + { + UIBackgroundTaskIdentifier identifier = (UIBackgroundTaskIdentifier)[native unsignedIntegerValue]; + // The system's expiration handler runs on the main thread and must end the task before it returns, so a + // main-thread caller ends it immediately instead of queueing it. + if ([NSThread isMainThread]) + { + [[UIApplication sharedApplication] endBackgroundTask:identifier]; + } + else + { + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] endBackgroundTask:identifier]; + }); + } + } +} + +int _GameLoversBackgroundTaskBegin(const char *name, GLBackgroundTaskExpiredCallback callback) +{ + NSString *taskName = name != NULL ? [NSString stringWithUTF8String:name] : @"GameLovers.BackgroundTask"; + __block int taskId = 0; + + GLRunOnMainSync(^{ + int candidate; + @synchronized (GLTaskLock()) + { + candidate = gNextTaskId++; + } + + UIBackgroundTaskIdentifier identifier = [[UIApplication sharedApplication] beginBackgroundTaskWithName:taskName expirationHandler:^{ + @synchronized (GLTaskLock()) + { + [gExpired addObject:@(candidate)]; + } + if (callback != NULL) callback(candidate); + GLEndNativeTask(candidate); + }]; + + if (identifier == UIBackgroundTaskInvalid) return; + + @synchronized (GLTaskLock()) + { + gTasks[@(candidate)] = @((NSUInteger)identifier); + } + taskId = candidate; + }); + + return taskId; +} + +void _GameLoversBackgroundTaskEnd(int taskId) +{ + GLEndNativeTask(taskId); + @synchronized (GLTaskLock()) + { + [gExpired removeObject:@(taskId)]; + } +} + +// Seconds left before suspension, or -1 while the application is in the foreground (no limit). +double _GameLoversBackgroundTaskRemaining(void) +{ + __block double remaining = -1; + GLRunOnMainSync(^{ + NSTimeInterval value = [UIApplication sharedApplication].backgroundTimeRemaining; + remaining = value > 1e9 ? -1 : value; + }); + return remaining; +} + +int _GameLoversBackgroundTaskIsExpired(int taskId) +{ + @synchronized (GLTaskLock()) + { + return [gExpired containsObject:@(taskId)] ? 1 : 0; + } +} diff --git a/Plugins/iOS/BackgroundTask.m.meta b/Plugins/iOS/BackgroundTask.m.meta new file mode 100644 index 0000000..a646f58 --- /dev/null +++ b/Plugins/iOS/BackgroundTask.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8624f5d404b9f4dbe9d515062471efec \ No newline at end of file diff --git a/Plugins/iOS/CloudSave.m b/Plugins/iOS/CloudSave.m new file mode 100644 index 0000000..ac2f815 --- /dev/null +++ b/Plugins/iOS/CloudSave.m @@ -0,0 +1,200 @@ +#import +#import +#import "GLPermissionGroups.h" + +#if GL_GAME_CENTER +#import +#endif + +// Completion callback into managed code. `result` is a tagged string: "K" on success or +// "E|" on failure. Always invoked on the main queue. +typedef void (*GLCloudSaveCallback)(int requestId, const char *result); + +static void GLReply(GLCloudSaveCallback callback, int requestId, NSString *reply) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (callback != NULL) callback(requestId, [reply UTF8String]); + }); +} + +#if GL_GAME_CENTER + +// GKErrorICloudUnavailable, spelled as its value because the constant is annotated with a newer availability than its introduction. +static const NSInteger GLErrorICloudUnavailable = 15; + +static NSString *GLReasonForError(NSError *error) +{ + if ([error.domain isEqualToString:GKErrorDomain]) + { + if (error.code == GKErrorNotAuthenticated) return @"NotAuthenticated"; + if (error.code == GLErrorICloudUnavailable) return @"CloudUnavailable"; + } + return @"Io"; +} + +static void GLReplyError(GLCloudSaveCallback callback, int requestId, NSError *error) +{ + GLReply(callback, requestId, [NSString stringWithFormat:@"E%@|%@", GLReasonForError(error), error.localizedDescription ?: @"Saved game request failed."]); +} + +static void GLReplyJson(GLCloudSaveCallback callback, int requestId, NSArray *items) +{ + NSData *json = [NSJSONSerialization dataWithJSONObject:@{ @"items": items } options:0 error:nil]; + GLReply(callback, requestId, [@"K" stringByAppendingString:[[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]]); +} + +static NSDictionary *GLEntryForSave(GKSavedGame *save, NSString *base64) +{ + NSMutableDictionary *entry = [@{ + @"name": save.name ?: @"", + @"description": @"", + @"modified": @([save.modificationDate timeIntervalSince1970]), + @"device": save.deviceName ?: @"", + } mutableCopy]; + if (base64 != nil) entry[@"data"] = base64; + return entry; +} + +// Saved games exist only for the signed-in player, and only when the user has an iCloud account for the app. +int _GameLoversCloudSaveIsAvailable(void) +{ + return [NSFileManager defaultManager].ubiquityIdentityToken != nil ? 1 : 0; +} + +void _GameLoversCloudSaveList(int requestId, GLCloudSaveCallback callback) +{ + [[GKLocalPlayer localPlayer] fetchSavedGamesWithCompletionHandler:^(NSArray * _Nullable savedGames, NSError * _Nullable error) { + if (error != nil) + { + GLReplyError(callback, requestId, error); + return; + } + + // Conflicting versions share a name; the list shows each slot once, with its newest version. + NSMutableDictionary *newest = [NSMutableDictionary dictionary]; + for (GKSavedGame *save in savedGames) + { + GKSavedGame *existing = newest[save.name]; + if (existing == nil || [save.modificationDate compare:existing.modificationDate] == NSOrderedDescending) newest[save.name] = save; + } + + NSMutableArray *items = [NSMutableArray array]; + for (GKSavedGame *save in newest.allValues) [items addObject:GLEntryForSave(save, nil)]; + GLReplyJson(callback, requestId, items); + }]; +} + +// Returns every stored version of the slot with its bytes (base64): none, one, or several when devices conflicted. +void _GameLoversCloudSaveLoadVersions(const char *slot, int requestId, GLCloudSaveCallback callback) +{ + NSString *name = [NSString stringWithUTF8String:slot]; + [[GKLocalPlayer localPlayer] fetchSavedGamesWithCompletionHandler:^(NSArray * _Nullable savedGames, NSError * _Nullable error) { + if (error != nil) + { + GLReplyError(callback, requestId, error); + return; + } + + NSMutableArray *matches = [NSMutableArray array]; + for (GKSavedGame *save in savedGames) if ([save.name isEqualToString:name]) [matches addObject:save]; + if (matches.count == 0) + { + GLReplyJson(callback, requestId, @[]); + return; + } + + NSMutableArray *items = [NSMutableArray array]; + dispatch_group_t group = dispatch_group_create(); + __block NSError *loadError = nil; + for (GKSavedGame *save in matches) + { + dispatch_group_enter(group); + [save loadDataWithCompletionHandler:^(NSData * _Nullable data, NSError * _Nullable dataError) { + @synchronized (items) + { + if (dataError != nil) loadError = dataError; + else [items addObject:GLEntryForSave(save, [data base64EncodedStringWithOptions:0])]; + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + if (loadError != nil) GLReplyError(callback, requestId, loadError); + else GLReplyJson(callback, requestId, items); + }); + }]; +} + +// Collapses conflicting versions of the slot to the chosen bytes. +void _GameLoversCloudSaveResolve(const char *slot, const char *base64, int requestId, GLCloudSaveCallback callback) +{ + NSString *name = [NSString stringWithUTF8String:slot]; + NSData *chosen = [[NSData alloc] initWithBase64EncodedString:[NSString stringWithUTF8String:base64] options:0]; + [[GKLocalPlayer localPlayer] fetchSavedGamesWithCompletionHandler:^(NSArray * _Nullable savedGames, NSError * _Nullable error) { + if (error != nil) + { + GLReplyError(callback, requestId, error); + return; + } + + NSMutableArray *matches = [NSMutableArray array]; + for (GKSavedGame *save in savedGames) if ([save.name isEqualToString:name]) [matches addObject:save]; + [[GKLocalPlayer localPlayer] resolveConflictingSavedGames:matches withData:chosen completionHandler:^(NSArray * _Nullable resolved, NSError * _Nullable resolveError) { + if (resolveError != nil) GLReplyError(callback, requestId, resolveError); + else GLReply(callback, requestId, @"K"); + }]; + }]; +} + +// The save keeps running if the app is backgrounded: the background task is held natively until the OS confirms, +// because Unity's main loop is paused while backgrounded and could not end it. +void _GameLoversCloudSaveSave(const char *slot, const char *base64, int requestId, GLCloudSaveCallback callback) +{ + NSString *name = [NSString stringWithUTF8String:slot]; + NSData *data = [[NSData alloc] initWithBase64EncodedString:[NSString stringWithUTF8String:base64] options:0]; + + __block UIBackgroundTaskIdentifier task = UIBackgroundTaskInvalid; + void (^endTask)(void) = ^{ + if (task != UIBackgroundTaskInvalid) + { + [[UIApplication sharedApplication] endBackgroundTask:task]; + task = UIBackgroundTaskInvalid; + } + }; + + dispatch_async(dispatch_get_main_queue(), ^{ + task = [[UIApplication sharedApplication] beginBackgroundTaskWithName:@"GameLovers.CloudSave" expirationHandler:endTask]; + [[GKLocalPlayer localPlayer] saveGameData:data withName:name completionHandler:^(GKSavedGame * _Nullable savedGame, NSError * _Nullable error) { + dispatch_async(dispatch_get_main_queue(), endTask); + if (error != nil) GLReplyError(callback, requestId, error); + else GLReply(callback, requestId, @"K"); + }]; + }); +} + +void _GameLoversCloudSaveDelete(const char *slot, int requestId, GLCloudSaveCallback callback) +{ + NSString *name = [NSString stringWithUTF8String:slot]; + [[GKLocalPlayer localPlayer] deleteSavedGamesWithName:name completionHandler:^(NSError * _Nullable error) { + if (error != nil) GLReplyError(callback, requestId, error); + else GLReply(callback, requestId, @"K"); + }]; +} + +#else + +// Cloud saves ride on Game Center: without the capability in the Mobile Services Config the bridge links no GameKit symbols. +static void GLCloudSaveDisabled(GLCloudSaveCallback callback, int requestId) +{ + GLReply(callback, requestId, @"ECloudUnavailable|Game Center is not enabled for this build. Turn on the Game Center capability in the Mobile Services Config."); +} + +int _GameLoversCloudSaveIsAvailable(void) { return 0; } +void _GameLoversCloudSaveList(int requestId, GLCloudSaveCallback callback) { GLCloudSaveDisabled(callback, requestId); } +void _GameLoversCloudSaveLoadVersions(const char *slot, int requestId, GLCloudSaveCallback callback) { GLCloudSaveDisabled(callback, requestId); } +void _GameLoversCloudSaveResolve(const char *slot, const char *base64, int requestId, GLCloudSaveCallback callback) { GLCloudSaveDisabled(callback, requestId); } +void _GameLoversCloudSaveSave(const char *slot, const char *base64, int requestId, GLCloudSaveCallback callback) { GLCloudSaveDisabled(callback, requestId); } +void _GameLoversCloudSaveDelete(const char *slot, int requestId, GLCloudSaveCallback callback) { GLCloudSaveDisabled(callback, requestId); } + +#endif diff --git a/Plugins/iOS/CloudSave.m.meta b/Plugins/iOS/CloudSave.m.meta new file mode 100644 index 0000000..29b1e13 --- /dev/null +++ b/Plugins/iOS/CloudSave.m.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: 9d3b5a7c1e2f4a6b8c0d2e4f6a8b0c1d +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: {} + Editor: + enabled: 0 + settings: + DefaultValueInitialized: true + iOS: + enabled: 1 + settings: + FrameworkDependencies: GameKit; + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/iOS/GLPermissionGroups.h b/Plugins/iOS/GLPermissionGroups.h new file mode 100644 index 0000000..6c32651 --- /dev/null +++ b/Plugins/iOS/GLPermissionGroups.h @@ -0,0 +1,18 @@ +#ifndef GLPermissionGroups_h +#define GLPermissionGroups_h + +// GL_GAME_CENTER gates the GameKit bridge the same way; the postprocessor defines it when the Game Center capability is enabled. +// Each GL_PERMISSION_ gates the code that references a privacy-protected iOS API. Without the build +// postprocessor every group compiles. When it sets GL_PERMISSIONS_DECLARED_ONLY it also defines GL_PERMISSION_=1 +// for each group whose usage description is present in the final Info.plist (see IosPermissionGroups.cs), so the +// binary references no API that App Store validation would flag as missing its purpose string. +#ifndef GL_PERMISSIONS_DECLARED_ONLY +#define GL_PERMISSION_CAMERA 1 +#define GL_PERMISSION_MICROPHONE 1 +#define GL_PERMISSION_LOCATION 1 +#define GL_PERMISSION_PHOTOS 1 +#define GL_PERMISSION_PHOTOS_ADD 1 +#define GL_GAME_CENTER 1 +#endif + +#endif diff --git a/Plugins/iOS/GLPermissionGroups.h.meta b/Plugins/iOS/GLPermissionGroups.h.meta new file mode 100644 index 0000000..c080538 --- /dev/null +++ b/Plugins/iOS/GLPermissionGroups.h.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c2d1f7a9e8b4d3aa6c0f1e2b3d4a5c6 diff --git a/Plugins/iOS/GamePlatform.m b/Plugins/iOS/GamePlatform.m new file mode 100644 index 0000000..dd0a8ca --- /dev/null +++ b/Plugins/iOS/GamePlatform.m @@ -0,0 +1,200 @@ +#import +#import +#import "GLPermissionGroups.h" + +#if GL_GAME_CENTER +#import +#endif + +extern UIViewController *UnityGetGLViewController(void); + +// Completion callback into managed code. `result` is NULL on failure. Always invoked on the main queue. +typedef void (*GLGamePlatformCallback)(int requestId, const char *result); + +static void GLComplete(GLGamePlatformCallback callback, int requestId, NSString *result) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (callback != NULL) callback(requestId, result != nil ? [result UTF8String] : NULL); + }); +} + +#if GL_GAME_CENTER + +#pragma mark - Authentication + +static int gAuthRequestId = 0; +static GLGamePlatformCallback gAuthCallback = NULL; + +static NSString *GLPlayerPayload(GKLocalPlayer *player) +{ + return [NSString stringWithFormat:@"%@\n%@", player.gamePlayerID, player.displayName ?: @""]; +} + +static void GLFinishAuthentication(NSString *payload) +{ + if (gAuthCallback == NULL) return; + + GLGamePlatformCallback callback = gAuthCallback; + int requestId = gAuthRequestId; + gAuthCallback = NULL; + GLComplete(callback, requestId, payload); +} + +void _GameLoversGamePlatformAuthenticate(int requestId, GLGamePlatformCallback callback) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + GKLocalPlayer *player = [GKLocalPlayer localPlayer]; + if (player.isAuthenticated) + { + GLComplete(callback, requestId, GLPlayerPayload(player)); + return; + } + + // A second request while the first is still open fails fast instead of replacing its callback. + if (gAuthCallback != NULL) + { + GLComplete(callback, requestId, nil); + return; + } + + gAuthRequestId = requestId; + gAuthCallback = callback; + + // Game Center calls the handler again after the sign-in sheet closes and whenever the account changes, + // so the pending request is completed once and later calls only refresh state. + player.authenticateHandler = ^(UIViewController * _Nullable viewController, NSError * _Nullable error) { + if (viewController != nil) + { + [UnityGetGLViewController() presentViewController:viewController animated:YES completion:nil]; + return; + } + + GKLocalPlayer *current = [GKLocalPlayer localPlayer]; + if (error != nil) NSLog(@"[GameLovers.MobileServices] Game Center sign-in failed: %@", error); + GLFinishAuthentication(current.isAuthenticated ? GLPlayerPayload(current) : nil); + }; + }); +} + +int _GameLoversGamePlatformIsAuthenticated(void) +{ + return [GKLocalPlayer localPlayer].isAuthenticated ? 1 : 0; +} + +#pragma mark - Leaderboards and achievements + +void _GameLoversGamePlatformReportScore(const char *leaderboardId, long long score, int requestId, GLGamePlatformCallback callback) +{ + NSString *identifier = [NSString stringWithUTF8String:leaderboardId]; + [GKLeaderboard submitScore:(NSInteger)score + context:0 + player:[GKLocalPlayer localPlayer] + leaderboardIDs:@[identifier] + completionHandler:^(NSError * _Nullable error) { + if (error != nil) NSLog(@"[GameLovers.MobileServices] Submitting the score failed: %@", error); + GLComplete(callback, requestId, error == nil ? @"ok" : nil); + }]; +} + +static void GLReportAchievement(NSString *identifier, double percent, int requestId, GLGamePlatformCallback callback) +{ + GKAchievement *achievement = [[GKAchievement alloc] initWithIdentifier:identifier]; + achievement.percentComplete = MIN(100.0, MAX(0.0, percent)); + achievement.showsCompletionBanner = YES; + [GKAchievement reportAchievements:@[achievement] withCompletionHandler:^(NSError * _Nullable error) { + if (error != nil) NSLog(@"[GameLovers.MobileServices] Reporting the achievement failed: %@", error); + GLComplete(callback, requestId, error == nil ? @"ok" : nil); + }]; +} + +void _GameLoversGamePlatformUnlockAchievement(const char *achievementId, int requestId, GLGamePlatformCallback callback) +{ + GLReportAchievement([NSString stringWithUTF8String:achievementId], 100.0, requestId, callback); +} + +// Game Center tracks a percentage, so the step count is converted with the achievement's total steps. +void _GameLoversGamePlatformIncrementAchievement(const char *achievementId, int steps, int totalSteps, int requestId, GLGamePlatformCallback callback) +{ + NSString *identifier = [NSString stringWithUTF8String:achievementId]; + if (totalSteps <= 0) + { + NSLog(@"[GameLovers.MobileServices] IncrementAchievement needs the achievement's total steps on iOS."); + GLComplete(callback, requestId, nil); + return; + } + + [GKAchievement loadAchievementsWithCompletionHandler:^(NSArray * _Nullable achievements, NSError * _Nullable error) { + if (error != nil) + { + NSLog(@"[GameLovers.MobileServices] Loading achievement progress failed: %@", error); + GLComplete(callback, requestId, nil); + return; + } + + double current = 0; + for (GKAchievement *achievement in achievements) + { + if ([achievement.identifier isEqualToString:identifier]) current = achievement.percentComplete; + } + + GLReportAchievement(identifier, current + (100.0 * steps / totalSteps), requestId, callback); + }]; +} + +#pragma mark - Game Center views + +@interface GLGameCenterDelegate : NSObject +@end + +static GLGameCenterDelegate *gGameCenterDelegate = nil; + +@implementation GLGameCenterDelegate +- (void)gameCenterViewControllerDidFinish:(GKGameCenterViewController *)gameCenterViewController +{ + [gameCenterViewController dismissViewControllerAnimated:YES completion:nil]; +} +@end + +static void GLPresentGameCenter(GKGameCenterViewController *controller) +{ + if (gGameCenterDelegate == nil) gGameCenterDelegate = [[GLGameCenterDelegate alloc] init]; + controller.gameCenterDelegate = gGameCenterDelegate; + [UnityGetGLViewController() presentViewController:controller animated:YES completion:nil]; +} + +void _GameLoversGamePlatformShowLeaderboards(const char *leaderboardId) +{ + NSString *identifier = leaderboardId != NULL ? [NSString stringWithUTF8String:leaderboardId] : @""; + dispatch_async(dispatch_get_main_queue(), ^{ + GKGameCenterViewController *controller = identifier.length > 0 + ? [[GKGameCenterViewController alloc] initWithLeaderboardID:identifier playerScope:GKLeaderboardPlayerScopeGlobal timeScope:GKLeaderboardTimeScopeAllTime] + : [[GKGameCenterViewController alloc] initWithState:GKGameCenterViewControllerStateLeaderboards]; + GLPresentGameCenter(controller); + }); +} + +void _GameLoversGamePlatformShowAchievements(void) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + GLPresentGameCenter([[GKGameCenterViewController alloc] initWithState:GKGameCenterViewControllerStateAchievements]); + }); +} + +#else + +// Game Center is opt-in: without the capability enabled in the Mobile Services Config the bridge links no GameKit symbols. +static void GLGameCenterDisabled(GLGamePlatformCallback callback, int requestId) +{ + NSLog(@"[GameLovers.MobileServices] Game Center is not enabled for this build. Turn on the Game Center capability in the Mobile Services Config."); + GLComplete(callback, requestId, nil); +} + +void _GameLoversGamePlatformAuthenticate(int requestId, GLGamePlatformCallback callback) { GLGameCenterDisabled(callback, requestId); } +int _GameLoversGamePlatformIsAuthenticated(void) { return 0; } +void _GameLoversGamePlatformReportScore(const char *leaderboardId, long long score, int requestId, GLGamePlatformCallback callback) { GLGameCenterDisabled(callback, requestId); } +void _GameLoversGamePlatformUnlockAchievement(const char *achievementId, int requestId, GLGamePlatformCallback callback) { GLGameCenterDisabled(callback, requestId); } +void _GameLoversGamePlatformIncrementAchievement(const char *achievementId, int steps, int totalSteps, int requestId, GLGamePlatformCallback callback) { GLGameCenterDisabled(callback, requestId); } +void _GameLoversGamePlatformShowLeaderboards(const char *leaderboardId) { NSLog(@"[GameLovers.MobileServices] Game Center is not enabled for this build."); } +void _GameLoversGamePlatformShowAchievements(void) { NSLog(@"[GameLovers.MobileServices] Game Center is not enabled for this build."); } + +#endif diff --git a/Plugins/iOS/GamePlatform.m.meta b/Plugins/iOS/GamePlatform.m.meta new file mode 100644 index 0000000..9429abb --- /dev/null +++ b/Plugins/iOS/GamePlatform.m.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: 7a1e9c2b4d6f48e3b5a0c7d8e9f1a2b3 +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: {} + Editor: + enabled: 0 + settings: + DefaultValueInitialized: true + iOS: + enabled: 1 + settings: + FrameworkDependencies: GameKit; + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/iOS/Media.m b/Plugins/iOS/Media.m new file mode 100644 index 0000000..38f0056 --- /dev/null +++ b/Plugins/iOS/Media.m @@ -0,0 +1,123 @@ +#import +#import +#import +#import "GLPermissionGroups.h" + +#if GL_PERMISSION_PHOTOS_ADD +#import +#endif + +extern UIViewController *UnityGetGLViewController(void); + +// Completion callback into managed code. `result` is NULL on failure or cancellation. Always invoked on the main queue. +typedef void (*GLMediaCallback)(int requestId, const char *result); + +static void GLCompleteMedia(GLMediaCallback callback, int requestId, NSString *result) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (callback != NULL) callback(requestId, result != nil ? [result UTF8String] : NULL); + }); +} + +#pragma mark - Save to gallery + +// Saving needs add-only photo authorization, so it only exists when NSPhotoLibraryAddUsageDescription is declared. +void _GameLoversMediaSave(const char *path, int isVideo, int requestId, GLMediaCallback callback) +{ +#if GL_PERMISSION_PHOTOS_ADD + NSURL *fileUrl = [NSURL fileURLWithPath:[NSString stringWithUTF8String:path]]; + [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ + if (isVideo != 0) + { + [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:fileUrl]; + } + else + { + [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:fileUrl]; + } + } completionHandler:^(BOOL success, NSError * _Nullable error) { + if (!success) NSLog(@"[GameLovers.MobileServices] SaveToGallery failed: %@", error); + GLCompleteMedia(callback, requestId, success ? @"ok" : nil); + }]; +#else + NSLog(@"[GameLovers.MobileServices] SaveToGallery needs a PhotoLibraryAddOnly usage description (NSPhotoLibraryAddUsageDescription); the save code is not compiled into this build."); + GLCompleteMedia(callback, requestId, nil); +#endif +} + +#pragma mark - Pick image + +@interface GLImagePickerDelegate : NSObject +@property (nonatomic, assign) int requestId; +@property (nonatomic, assign) GLMediaCallback callback; +@end + +static GLImagePickerDelegate *gActivePicker = nil; + +@implementation GLImagePickerDelegate + +- (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14)) +{ + [picker dismissViewControllerAnimated:YES completion:nil]; + + int requestId = _requestId; + GLMediaCallback callback = _callback; + gActivePicker = nil; + + NSItemProvider *provider = results.firstObject.itemProvider; + if (provider == nil || ![provider hasItemConformingToTypeIdentifier:@"public.image"]) + { + GLCompleteMedia(callback, requestId, nil); + return; + } + + // The provider's file is deleted when the handler returns, so it is copied to a path the game owns first. + [provider loadFileRepresentationForTypeIdentifier:@"public.image" completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { + if (url == nil) + { + NSLog(@"[GameLovers.MobileServices] PickImage failed to load the selection: %@", error); + GLCompleteMedia(callback, requestId, nil); + return; + } + + NSString *extension = url.pathExtension.length > 0 ? url.pathExtension : @"img"; + NSString *name = [NSString stringWithFormat:@"gamelovers-picked-%@.%@", [[NSUUID UUID] UUIDString], extension]; + NSURL *destination = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:name]]; + NSError *copyError = nil; + if (![[NSFileManager defaultManager] copyItemAtURL:url toURL:destination error:©Error]) + { + NSLog(@"[GameLovers.MobileServices] PickImage failed to copy the selection: %@", copyError); + GLCompleteMedia(callback, requestId, nil); + return; + } + + GLCompleteMedia(callback, requestId, destination.path); + }]; +} + +@end + +// The system picker runs out of process and needs neither a permission nor a usage description. +void _GameLoversMediaPickImage(int requestId, GLMediaCallback callback) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (gActivePicker != nil) + { + GLCompleteMedia(callback, requestId, nil); + return; + } + + PHPickerConfiguration *configuration = [[PHPickerConfiguration alloc] init]; + configuration.selectionLimit = 1; + configuration.filter = [PHPickerFilter imagesFilter]; + + GLImagePickerDelegate *delegate = [[GLImagePickerDelegate alloc] init]; + delegate.requestId = requestId; + delegate.callback = callback; + gActivePicker = delegate; + + PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:configuration]; + picker.delegate = delegate; + [UnityGetGLViewController() presentViewController:picker animated:YES completion:nil]; + }); +} diff --git a/Plugins/iOS/Media.m.meta b/Plugins/iOS/Media.m.meta new file mode 100644 index 0000000..0bbab93 --- /dev/null +++ b/Plugins/iOS/Media.m.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: 0b6f6c0e5d2a4a7f9d3e8c1a4b7e2f60 +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: {} + Editor: + enabled: 0 + settings: + DefaultValueInitialized: true + iOS: + enabled: 1 + settings: + FrameworkDependencies: Photos;PhotosUI; + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/iOS/NativeUi.m b/Plugins/iOS/NativeUi.m index cbaedd4..c42dd8a 100644 --- a/Plugins/iOS/NativeUi.m +++ b/Plugins/iOS/NativeUi.m @@ -1,5 +1,6 @@ #import #import +#import extern UIViewController *UnityGetGLViewController(); @@ -147,3 +148,18 @@ void _GameLoversShare(const char *text, const char *url, const char *imagePath) [root presentViewController:activityVc animated:YES completion:nil]; }); } + +// In-app browser. SFSafariViewController only accepts http and https URLs, which the managed caller guarantees. +void _GameLoversOpenInAppBrowser(const char *url) +{ + NSURL *target = [NSURL URLWithString:[NSString stringWithUTF8String:url]]; + if (target == nil) + { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + SFSafariViewController *browser = [[SFSafariViewController alloc] initWithURL:target]; + [UnityGetGLViewController() presentViewController:browser animated:YES completion:nil]; + }); +} diff --git a/Plugins/iOS/NativeUi.m.meta b/Plugins/iOS/NativeUi.m.meta index 8633b4b..850bbda 100644 --- a/Plugins/iOS/NativeUi.m.meta +++ b/Plugins/iOS/NativeUi.m.meta @@ -2,7 +2,7 @@ fileFormatVersion: 2 guid: f75285768bb8048ce83a504d68d9ea72 PluginImporter: externalObjects: {} - serializedVersion: 2 + serializedVersion: 3 iconMap: {} executionOrder: {} defineConstraints: [] @@ -11,25 +11,18 @@ PluginImporter: isExplicitlyReferenced: 0 validateReferences: 1 platformData: - - first: - Any: - second: + Any: enabled: 0 settings: {} - - first: - Editor: Editor - second: + Editor: enabled: 0 settings: DefaultValueInitialized: true - - first: - iPhone: iOS - second: + iOS: enabled: 1 - settings: {} - - first: - tvOS: tvOS - second: + settings: + FrameworkDependencies: StoreKit;SafariServices; + tvOS: enabled: 1 settings: {} userData: diff --git a/Plugins/iOS/Permissions.m b/Plugins/iOS/Permissions.m index b60ebb0..7513d2c 100644 --- a/Plugins/iOS/Permissions.m +++ b/Plugins/iOS/Permissions.m @@ -1,8 +1,18 @@ #import +#import +#import + +#import "GLPermissionGroups.h" + +#if GL_PERMISSION_CAMERA || GL_PERMISSION_MICROPHONE #import +#endif +#if GL_PERMISSION_LOCATION #import +#endif +#if GL_PERMISSION_PHOTOS || GL_PERMISSION_PHOTOS_ADD #import -#import +#endif extern void UnitySendMessage(const char *gameObject, const char *method, const char *message); @@ -24,68 +34,216 @@ typedef NS_ENUM(NSInteger, GLPermissionStatus) GLPermissionStatusNotDetermined = 0, GLPermissionStatusDenied = 1, GLPermissionStatusGranted = 2, - GLPermissionStatusRestricted = 3 + GLPermissionStatusRestricted = 3, + GLPermissionStatusLimited = 4 }; +static void GLSendResult(int requestId, NSString *callbackGameObject, NSString *callbackMethod, GLPermissionStatus status) +{ + NSString *payload = [NSString stringWithFormat:@"%d:%ld", requestId, (long)status]; + UnitySendMessage([callbackGameObject UTF8String], [callbackMethod UTF8String], [payload UTF8String]); +} + +__attribute__((unused)) static void GLSendUndeclared(int requestId, NSString *callbackGameObject, NSString *callbackMethod, const char *group) +{ + NSLog(@"[GameLovers.MobileServices] The %s permission group is not compiled into this build. Add its usage description to the Mobile Services Config.", group); + GLSendResult(requestId, callbackGameObject, callbackMethod, GLPermissionStatusNotDetermined); +} + +#pragma mark - Notifications (always compiled: no Info.plist purpose string exists for them) + +static GLPermissionStatus MapUNAuthorizationStatus(UNAuthorizationStatus s) +{ + switch (s) + { + case UNAuthorizationStatusAuthorized: return GLPermissionStatusGranted; + case UNAuthorizationStatusProvisional: return GLPermissionStatusLimited; + case UNAuthorizationStatusDenied: return GLPermissionStatusDenied; + case UNAuthorizationStatusEphemeral: return GLPermissionStatusGranted; + case UNAuthorizationStatusNotDetermined: + default: return GLPermissionStatusNotDetermined; + } +} + +// The notification state is only readable asynchronously, so Check serves a cache that is refreshed at +// startup, whenever the app becomes active (the player may have changed it in Settings) and after each request. +static GLPermissionStatus gNotificationStatus = GLPermissionStatusNotDetermined; +static BOOL gNotificationStatusValid = NO; +static dispatch_semaphore_t gNotificationReady = nil; + +static void GLRefreshNotificationStatus(void (^completion)(GLPermissionStatus status)) +{ + [[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) { + GLPermissionStatus status = MapUNAuthorizationStatus(settings.authorizationStatus); + BOOL firstValue = !gNotificationStatusValid; + gNotificationStatus = status; + gNotificationStatusValid = YES; + if (firstValue) dispatch_semaphore_signal(gNotificationReady); + if (completion != nil) completion(status); + }]; +} + +static void GLEnsureNotificationTracking(void) +{ + static dispatch_once_t once; + dispatch_once(&once, ^{ + gNotificationReady = dispatch_semaphore_create(0); + [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:nil + usingBlock:^(NSNotification * _Nonnull note) { + GLRefreshNotificationStatus(nil); + }]; + GLRefreshNotificationStatus(nil); + }); +} + +static GLPermissionStatus GLCheckNotifications(void) +{ + GLEnsureNotificationTracking(); + if (!gNotificationStatusValid) + { + // Cold start only: wait briefly for the first read; every later call is served from the cache. + dispatch_semaphore_wait(gNotificationReady, dispatch_time(DISPATCH_TIME_NOW, 250 * NSEC_PER_MSEC)); + } + return gNotificationStatusValid ? gNotificationStatus : GLPermissionStatusNotDetermined; +} + +static void GLRequestNotifications(int requestId, NSString *goName, NSString *methodName) +{ + GLEnsureNotificationTracking(); + UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound; + [[UNUserNotificationCenter currentNotificationCenter] + requestAuthorizationWithOptions:options + completionHandler:^(BOOL granted, NSError * _Nullable error) { + GLRefreshNotificationStatus(^(GLPermissionStatus status) { + GLSendResult(requestId, goName, methodName, status); + }); + }]; +} + +#pragma mark - Camera and microphone + +#if GL_PERMISSION_CAMERA || GL_PERMISSION_MICROPHONE +static GLPermissionStatus MapAVAuthorizationStatus(AVAuthorizationStatus s) +{ + switch (s) + { + case AVAuthorizationStatusAuthorized: return GLPermissionStatusGranted; + case AVAuthorizationStatusDenied: return GLPermissionStatusDenied; + case AVAuthorizationStatusRestricted: return GLPermissionStatusRestricted; + case AVAuthorizationStatusNotDetermined: + default: return GLPermissionStatusNotDetermined; + } +} + +static GLPermissionStatus GLCheckAV(AVMediaType mediaType) +{ + return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:mediaType]); +} + +static void GLRequestAV(AVMediaType mediaType, int requestId, NSString *goName, NSString *methodName) +{ + [AVCaptureDevice requestAccessForMediaType:mediaType completionHandler:^(BOOL granted) { + // The post-request status keeps Restricted distinct from a user denial. + GLSendResult(requestId, goName, methodName, GLCheckAV(mediaType)); + }]; +} +#endif + +#pragma mark - Location + +#if GL_PERMISSION_LOCATION +static GLPermissionStatus GLLocationStatus(CLLocationManager *manager, BOOL requestedAlways) +{ + BOOL reducedAccuracy = manager.accuracyAuthorization == CLAccuracyAuthorizationReducedAccuracy; + switch (manager.authorizationStatus) + { + case kCLAuthorizationStatusAuthorizedAlways: + return reducedAccuracy ? GLPermissionStatusLimited : GLPermissionStatusGranted; + case kCLAuthorizationStatusAuthorizedWhenInUse: + return (requestedAlways || reducedAccuracy) ? GLPermissionStatusLimited : GLPermissionStatusGranted; + case kCLAuthorizationStatusDenied: + return GLPermissionStatusDenied; + case kCLAuthorizationStatusRestricted: + return GLPermissionStatusRestricted; + case kCLAuthorizationStatusNotDetermined: + default: + return GLPermissionStatusNotDetermined; + } +} + // Held to keep CLLocationManager alive long enough for the delegate callback. @interface GLLocationDelegate : NSObject @property (nonatomic, assign) int requestId; +@property (nonatomic, assign) BOOL requestedAlways; @property (nonatomic, copy) NSString *callbackGameObject; @property (nonatomic, copy) NSString *callbackMethod; @property (nonatomic, strong) CLLocationManager *manager; @end +static NSMutableArray *gLocationDelegates = nil; + @implementation GLLocationDelegate - (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { - GLPermissionStatus status = GLPermissionStatusNotDetermined; - switch (manager.authorizationStatus) + GLPermissionStatus status = GLLocationStatus(manager, _requestedAlways); + if (status == GLPermissionStatusNotDetermined) { - 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. + 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]); + GLSendResult(_requestId, _callbackGameObject, _callbackMethod, status); _manager.delegate = nil; _manager = nil; + [gLocationDelegates removeObject:self]; } @end -static NSMutableArray *gLocationDelegates = nil; +static GLPermissionStatus GLCheckLocation(BOOL requestedAlways) +{ + return GLLocationStatus([[CLLocationManager alloc] init], requestedAlways); +} -static GLPermissionStatus MapAVAuthorizationStatus(AVAuthorizationStatus s) +static void GLRequestLocation(BOOL always, int requestId, NSString *goName, NSString *methodName) { - switch (s) + if (gLocationDelegates == nil) { - case AVAuthorizationStatusAuthorized: return GLPermissionStatusGranted; - case AVAuthorizationStatusDenied: return GLPermissionStatusDenied; - case AVAuthorizationStatusRestricted: return GLPermissionStatusRestricted; - case AVAuthorizationStatusNotDetermined: return GLPermissionStatusNotDetermined; - default: return GLPermissionStatusNotDetermined; + gLocationDelegates = [NSMutableArray array]; + } + + GLLocationDelegate *delegate = [[GLLocationDelegate alloc] init]; + delegate.requestId = requestId; + delegate.requestedAlways = always; + delegate.callbackGameObject = goName; + delegate.callbackMethod = methodName; + delegate.manager = [[CLLocationManager alloc] init]; + delegate.manager.delegate = delegate; + [gLocationDelegates addObject:delegate]; + + if (always) + { + [delegate.manager requestAlwaysAuthorization]; + } + else + { + [delegate.manager requestWhenInUseAuthorization]; } } +#endif +#pragma mark - Photos + +#if GL_PERMISSION_PHOTOS || GL_PERMISSION_PHOTOS_ADD static GLPermissionStatus MapPHAuthorizationStatus(PHAuthorizationStatus s) { switch (s) { case PHAuthorizationStatusAuthorized: return GLPermissionStatusGranted; - case PHAuthorizationStatusLimited: return GLPermissionStatusGranted; + case PHAuthorizationStatusLimited: return GLPermissionStatusLimited; case PHAuthorizationStatusDenied: return GLPermissionStatusDenied; case PHAuthorizationStatusRestricted: return GLPermissionStatusRestricted; case PHAuthorizationStatusNotDetermined: @@ -93,175 +251,101 @@ static GLPermissionStatus MapPHAuthorizationStatus(PHAuthorizationStatus s) } } -static GLPermissionStatus MapUNAuthorizationStatus(UNAuthorizationStatus s) +static GLPermissionStatus GLCheckPhotos(PHAccessLevel level) { - switch (s) - { - case UNAuthorizationStatusAuthorized: return GLPermissionStatusGranted; - case UNAuthorizationStatusProvisional: return GLPermissionStatusGranted; - case UNAuthorizationStatusDenied: return GLPermissionStatusDenied; - case UNAuthorizationStatusEphemeral: return GLPermissionStatusGranted; - case UNAuthorizationStatusNotDetermined: - default: return GLPermissionStatusNotDetermined; - } + return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:level]); } -static GLPermissionStatus MapCLAuthorizationStatusValue(CLAuthorizationStatus s) +static void GLRequestPhotos(PHAccessLevel level, int requestId, NSString *goName, NSString *methodName) { - switch (s) - { - case kCLAuthorizationStatusAuthorizedAlways: - case kCLAuthorizationStatusAuthorizedWhenInUse: - return GLPermissionStatusGranted; - case kCLAuthorizationStatusDenied: - return GLPermissionStatusDenied; - case kCLAuthorizationStatusRestricted: - return GLPermissionStatusRestricted; - case kCLAuthorizationStatusNotDetermined: - default: - return GLPermissionStatusNotDetermined; - } + [PHPhotoLibrary requestAuthorizationForAccessLevel:level handler:^(PHAuthorizationStatus status) { + GLSendResult(requestId, goName, methodName, MapPHAuthorizationStatus(status)); + }]; } +#endif + +#pragma mark - Exports int _GameLoversPermissionsCheck(int permissionId) { - GLAppPermission p = (GLAppPermission)permissionId; - switch (p) + switch ((GLAppPermission)permissionId) { +#if GL_PERMISSION_CAMERA case GLAppPermissionCamera: - return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]); + return GLCheckAV(AVMediaTypeVideo); +#endif +#if GL_PERMISSION_MICROPHONE case GLAppPermissionMicrophone: - return MapAVAuthorizationStatus([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]); + return GLCheckAV(AVMediaTypeAudio); +#endif +#if GL_PERMISSION_LOCATION case GLAppPermissionLocationWhenInUse: + return GLCheckLocation(NO); case GLAppPermissionLocationAlways: - return MapCLAuthorizationStatusValue([CLLocationManager authorizationStatus]); + return GLCheckLocation(YES); +#endif +#if GL_PERMISSION_PHOTOS case GLAppPermissionPhotoLibrary: - if (@available(iOS 14.0, *)) - { - return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]); - } - return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]); + return GLCheckPhotos(PHAccessLevelReadWrite); +#endif +#if GL_PERMISSION_PHOTOS_ADD case GLAppPermissionPhotoLibraryAddOnly: - if (@available(iOS 14.0, *)) - { - return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelAddOnly]); - } - return MapPHAuthorizationStatus([PHPhotoLibrary authorizationStatus]); + return GLCheckPhotos(PHAccessLevelAddOnly); +#endif 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 GLCheckNotifications(); + default: + return GLPermissionStatusNotDetermined; } - 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) + switch ((GLAppPermission)permissionId) { case GLAppPermissionCamera: - { - [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) { - GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], - granted ? GLPermissionStatusGranted : GLPermissionStatusDenied); - }]; +#if GL_PERMISSION_CAMERA + GLRequestAV(AVMediaTypeVideo, requestId, goName, methodName); +#else + GLSendUndeclared(requestId, goName, methodName, "camera"); +#endif return; - } case GLAppPermissionMicrophone: - { - [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) { - GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], - granted ? GLPermissionStatusGranted : GLPermissionStatusDenied); - }]; +#if GL_PERMISSION_MICROPHONE + GLRequestAV(AVMediaTypeAudio, requestId, goName, methodName); +#else + GLSendUndeclared(requestId, goName, methodName, "microphone"); +#endif 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]; - } +#if GL_PERMISSION_LOCATION + GLRequestLocation(permissionId == GLAppPermissionLocationAlways, requestId, goName, methodName); +#else + GLSendUndeclared(requestId, goName, methodName, "location"); +#endif 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)); - }]; - } +#if GL_PERMISSION_PHOTOS + GLRequestPhotos(PHAccessLevelReadWrite, requestId, goName, methodName); +#else + GLSendUndeclared(requestId, goName, methodName, "photos"); +#endif 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)); - }]; - } +#if GL_PERMISSION_PHOTOS_ADD + GLRequestPhotos(PHAccessLevelAddOnly, requestId, goName, methodName); +#else + GLSendUndeclared(requestId, goName, methodName, "photos add-only"); +#endif 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); - }]; + GLRequestNotifications(requestId, goName, methodName); return; - } } - GLSendResult(requestId, [goName UTF8String], [methodName UTF8String], GLPermissionStatusNotDetermined); + GLSendResult(requestId, goName, methodName, GLPermissionStatusNotDetermined); } diff --git a/Plugins/iOS/Permissions.m.meta b/Plugins/iOS/Permissions.m.meta index 959f087..0714dc3 100644 --- a/Plugins/iOS/Permissions.m.meta +++ b/Plugins/iOS/Permissions.m.meta @@ -1,2 +1,33 @@ fileFormatVersion: 2 -guid: 13fd05a91c602491a95d0e2c2a105ffe \ No newline at end of file +guid: 13fd05a91c602491a95d0e2c2a105ffe +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: {} + Editor: + enabled: 0 + settings: + DefaultValueInitialized: true + VisionOS: + enabled: 1 + settings: {} + iOS: + enabled: 1 + settings: + FrameworkDependencies: CoreLocation;Photos; + tvOS: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index ab173d9..34d2b19 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GameLovers Mobile Services -Unity 6 services for local notifications, native UI, haptics, permissions, App Tracking Transparency, deep links, gestures, and mobile build tooling. +Unity 6 services for local notifications, native UI, media, haptics, permissions, App Tracking Transparency, deep links, shortcuts, game services and cloud saves, gestures, and mobile build tooling. [![Unity](https://img.shields.io/badge/Unity-6000.0%20%7C%206000.3%20%7C%206000.5-blue.svg)](https://unity.com/download) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) @@ -8,7 +8,7 @@ Unity 6 services for local notifications, native UI, haptics, permissions, App T ## Scope -Use Mobile Services to isolate platform-specific behavior behind Unity-friendly APIs. It provides **local** notifications, not remote push delivery; it also does not provide connectivity or store fallback services. The package is pipeline-neutral. +Use Mobile Services to isolate platform-specific behavior behind Unity-friendly APIs. It provides **local** notifications, not remote push delivery; it also does not provide in-app purchases, connectivity, a JavaScript-bridged WebView, or store fallback services. Game Center and Google Play Games (sign-in, leaderboards, achievements, cloud saves) are opt-in through the Mobile Services Config. The package is pipeline-neutral. ## Unity compatibility @@ -32,7 +32,7 @@ Use Mobile Services to isolate platform-specific behavior behind Unity-friendly ```json { "dependencies": { - "com.gamelovers.mobileservices": "https://github.com/CoderGamester/Unity-MobileServices.git#1.2.0" + "com.gamelovers.mobileservices": "https://github.com/CoderGamester/Unity-MobileServices.git#2.0.0" } } ``` @@ -68,21 +68,51 @@ IDisposable ownedNotifications = notifications; Use the specific subsystem namespaces—`Notifications`, `Haptics`, `NativeUi`, and `Device`—rather than assuming one umbrella import exposes every type. +For safe-area UI padding, use `com.gamelovers.uiservice` (`SafeAreaPanelView`/`SafeAreaContainer`). + ## Services | Area | Provides | | --- | --- | -| Native UI | Dismissible or blocking alerts, action sheets, toasts, review requests, and sharing | -| Notifications | Local notification channels, scheduling, and management | +| Native UI | Dismissible or blocking alerts, action sheets, toasts, review requests, and sharing (including screenshots), gallery saving, image picking, and an in-app browser | +| Notifications | Local notification channels, scheduling, custom data, and opened-notification routing | | Haptics | Presets, custom output, and bounded loops | -| Device | Permissions, ATT, deep links, safe-area and device helpers | +| Device | Permissions, ATT, deep links, background tasks, home-screen shortcuts, store and Settings links, and device helpers | +| Game Platform | Opt-in Game Center and Google Play Games sign-in, leaderboards, achievements and slot-based cloud saves | | Gestures | Gesture controller for explicit gesture input ownership | -| Editor tooling | Device Simulator integration and build helpers | +| Editor tooling | Device Simulator integration, optional haptics diagnostics, and build helpers | Runtime alert calls render an interactive platform-shaped mock in the Game view even when the Device Simulator window is closed. Prefer `ShowAlertPopUpAsync` for a selected-index result on Unity's main thread; legacy callbacks also switch through Unity's `Awaitable` scheduler. The editor simulator is for exercising application paths; it is not a substitute for device permission, notification-delivery, review, or native-build validation. -## Sample and support +## Documentation + +| Topic | Page | +| --- | --- | +| Native UI, media, sharing, in-app browser | [native-ui.md](docs/native-ui.md) | +| Notifications | [notifications.md](docs/notifications.md) | +| Haptics | [haptics.md](docs/haptics.md) | +| Gestures | [gestures.md](docs/gestures.md) | +| Device: permissions, ATT, deep links, shortcuts, background tasks | [device.md](docs/device.md) | +| Game Platform: Game Center and Play Games | [game-platform.md](docs/game-platform.md) | +| Cloud Save | [cloud-save.md](docs/cloud-save.md) | +| Build configuration: config asset, plist, manifest and Gradle changes | [build-configuration.md](docs/build-configuration.md) | +| Device Simulator panel and in-Game-view overlay | [device-simulator.md](docs/device-simulator.md) | +| Samples | [samples.md](docs/samples.md) | +| Troubleshooting | [troubleshooting.md](docs/troubleshooting.md) | + +## Common issues + +| Symptom | First thing to check | +| --- | --- | +| A native call does nothing in the Editor | Editor backends are simulations or no-ops. Use the Device Simulator panel, or test on a device. | +| An iOS permission request never prompts | The usage description is missing from the Mobile Services Config, so the group is not compiled in. | +| `RequestReview()` does nothing on Android | The Play review dependency is turned off or missing from the generated Gradle project. | +| An Android notification never shows | Register at least one `GameNotificationChannel` in the `MobileNotificationService` constructor. | + +The full symptom-to-fix list is in [troubleshooting.md](docs/troubleshooting.md). + +## Samples and support Import **Mobile Services Samples** from Package Manager. Its four scenes—Overview, Haptics, Notifications, and Links—share one sample player; use its [README](Samples~/MobileServicesSamples/README.md) for scene prerequisites and build tooling. -See [docs](docs/README.md), [CHANGELOG.md](CHANGELOG.md), and [issues](https://github.com/CoderGamester/Unity-MobileServices/issues). +See [CHANGELOG.md](CHANGELOG.md) and [issues](https://github.com/CoderGamester/Unity-MobileServices/issues). diff --git a/Runtime/Device/Background.meta b/Runtime/Device/Background.meta new file mode 100644 index 0000000..c3f1b2b --- /dev/null +++ b/Runtime/Device/Background.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0165117fb57b44094aa83e546adc5c7a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Background/BackgroundTaskHandle.cs b/Runtime/Device/Background/BackgroundTaskHandle.cs new file mode 100644 index 0000000..78e747a --- /dev/null +++ b/Runtime/Device/Background/BackgroundTaskHandle.cs @@ -0,0 +1,54 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// A running background task. Its members may be read from any thread, so the protected work can poll + /// between steps. + /// + public sealed class BackgroundTaskHandle : IDisposable + { + private readonly Action _end; + private readonly Func _remainingTime; + private readonly Func _isExpired; + private volatile bool _disposed; + + /// The label passed to . + public string Name { get; } + + /// True when the system has run out of time and is about to suspend the application. + public bool IsExpired => !_disposed && _isExpired(); + + /// True until the task is disposed or expires. + public bool IsActive => !_disposed && !_isExpired(); + + /// + /// Time left before the system suspends the application, or when the + /// application is in the foreground or the platform sets no limit. + /// + public TimeSpan RemainingTime => _disposed ? TimeSpan.Zero : _remainingTime(); + + internal BackgroundTaskHandle(string name, Action end, Func remainingTime, Func isExpired) + { + Name = name; + _end = end; + _remainingTime = remainingTime; + _isExpired = isExpired; + } + + /// + /// Ends the task. Calling it again has no effect. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _end(); + } + } +} diff --git a/Runtime/Device/Background/BackgroundTaskHandle.cs.meta b/Runtime/Device/Background/BackgroundTaskHandle.cs.meta new file mode 100644 index 0000000..bbaa3f5 --- /dev/null +++ b/Runtime/Device/Background/BackgroundTaskHandle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5fbef7f6eeb5f4900921dbe477af42ad \ No newline at end of file diff --git a/Runtime/Device/Background/BackgroundTaskService.cs b/Runtime/Device/Background/BackgroundTaskService.cs new file mode 100644 index 0000000..61e25e3 --- /dev/null +++ b/Runtime/Device/Background/BackgroundTaskService.cs @@ -0,0 +1,77 @@ +using System; +#if UNITY_IOS && !UNITY_EDITOR +using System.Collections.Generic; +using System.Runtime.InteropServices; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + public sealed class BackgroundTaskService : IBackgroundTaskService + { +#if UNITY_IOS && !UNITY_EDITOR + private delegate void ExpirationCallback(int taskId); + + private static readonly object _callbackLock = new object(); + private static readonly Dictionary _expirationCallbacks = new Dictionary(); + + [DllImport("__Internal")] private static extern int _GameLoversBackgroundTaskBegin(string name, ExpirationCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversBackgroundTaskEnd(int taskId); + [DllImport("__Internal")] private static extern double _GameLoversBackgroundTaskRemaining(); + [DllImport("__Internal")] private static extern int _GameLoversBackgroundTaskIsExpired(int taskId); +#endif + + /// + public BackgroundTaskHandle Begin(string name, Action onExpiring = null) + { + var label = string.IsNullOrEmpty(name) ? "GameLovers.BackgroundTask" : name; +#if UNITY_IOS && !UNITY_EDITOR + var taskId = _GameLoversBackgroundTaskBegin(label, OnNativeExpired); + if (taskId == 0) + { + return new BackgroundTaskHandle(label, () => { }, () => TimeSpan.Zero, () => true); + } + + if (onExpiring != null) + { + lock (_callbackLock) _expirationCallbacks[taskId] = onExpiring; + } + + return new BackgroundTaskHandle( + label, + () => + { + lock (_callbackLock) _expirationCallbacks.Remove(taskId); + _GameLoversBackgroundTaskEnd(taskId); + }, + () => + { + var seconds = _GameLoversBackgroundTaskRemaining(); + return seconds < 0 ? TimeSpan.MaxValue : TimeSpan.FromSeconds(seconds); + }, + () => _GameLoversBackgroundTaskIsExpired(taskId) != 0); +#else + return new BackgroundTaskHandle(label, () => { }, () => TimeSpan.MaxValue, () => false); +#endif + } + +#if UNITY_IOS && !UNITY_EDITOR + // Runs on the iOS main thread from the system's expiration handler; the native layer ends the task afterwards. + [AOT.MonoPInvokeCallback(typeof(ExpirationCallback))] + private static void OnNativeExpired(int taskId) + { + Action callback; + lock (_callbackLock) _expirationCallbacks.TryGetValue(taskId, out callback); + try + { + callback?.Invoke(); + } + catch (Exception exception) + { + UnityEngine.Debug.LogError($"[GameLovers.MobileServices] Background task expiration callback failed: {exception.Message}"); + } + } +#endif + } +} diff --git a/Runtime/Device/Background/BackgroundTaskService.cs.meta b/Runtime/Device/Background/BackgroundTaskService.cs.meta new file mode 100644 index 0000000..ab6d22b --- /dev/null +++ b/Runtime/Device/Background/BackgroundTaskService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ede72c5ac97c5442485292cf31620bd6 \ No newline at end of file diff --git a/Runtime/Device/Background/IBackgroundTaskService.cs b/Runtime/Device/Background/IBackgroundTaskService.cs new file mode 100644 index 0000000..80553a6 --- /dev/null +++ b/Runtime/Device/Background/IBackgroundTaskService.cs @@ -0,0 +1,35 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// Asks the operating system for extra execution time when the application moves to the background, so a short + /// task such as an upload, a save or a queue flush can finish. + /// + /// + /// + /// Unity's main loop is paused while the application is in the background, so the protected work must run off + /// the main thread (a thread-pool task, a native upload or file I/O). Awaitable and other main-thread + /// continuations do not resume until the application returns to the foreground. + /// + /// + /// iOS grants roughly 30 seconds. On Android the call is a pass-through: the process keeps running for a + /// short grace period but the platform offers no per-task extension, so use a foreground service or + /// WorkManager outside this package for long work. + /// + /// + public interface IBackgroundTaskService + { + /// + /// Begins a background task. Dispose the returned handle as soon as the work finishes; the system ends the + /// task and may terminate the application if the handle is left open past the time limit. + /// + /// A short label for diagnostics. + /// + /// Optional callback raised on the main thread when the system is about to suspend the application. The task + /// ends right after it returns, so keep it to setting a cancellation flag. + /// + BackgroundTaskHandle Begin(string name, Action onExpiring = null); + } +} diff --git a/Runtime/Device/Background/IBackgroundTaskService.cs.meta b/Runtime/Device/Background/IBackgroundTaskService.cs.meta new file mode 100644 index 0000000..4c577b9 --- /dev/null +++ b/Runtime/Device/Background/IBackgroundTaskService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 745090ddd708f47b4ae1b4334baefac6 \ No newline at end of file diff --git a/Runtime/Device/DeepLinks/DeepLinkService.cs b/Runtime/Device/DeepLinks/DeepLinkService.cs index 92b3bd1..be022ef 100644 --- a/Runtime/Device/DeepLinks/DeepLinkService.cs +++ b/Runtime/Device/DeepLinks/DeepLinkService.cs @@ -1,4 +1,5 @@ using System; +using GameLovers.MobileServices.Device.Internal; using UnityEngine; // ReSharper disable once CheckNamespace @@ -9,6 +10,9 @@ public sealed class DeepLinkService : IDeepLinkService, IDisposable { private Action _onLinkActivated; private Uri _pendingColdStartLink; +#if UNITY_IOS && !UNITY_EDITOR + private readonly DeviceServicesHost _shortcutHost; +#endif /// public Uri PendingColdStartLink => _pendingColdStartLink; @@ -40,12 +44,23 @@ public DeepLinkService() { _pendingColdStartLink = TryParse(Application.absoluteURL); } + +#if UNITY_IOS && !UNITY_EDITOR + // iOS delivers a tapped home-screen shortcut natively, not through Unity's deep-link callback, so it is collected + // at start-up (a cold start) and whenever the app returns to the foreground. + _shortcutHost = DeviceServicesHost.Instance; + _shortcutHost.RegisterFocusChanged(OnShortcutFocusChanged); + DeliverPendingShortcut(); +#endif } /// public void Dispose() { Application.deepLinkActivated -= OnDeepLinkActivated; +#if UNITY_IOS && !UNITY_EDITOR + if (_shortcutHost != null) _shortcutHost.UnregisterFocusChanged(OnShortcutFocusChanged); +#endif _onLinkActivated = null; _pendingColdStartLink = null; } @@ -67,6 +82,30 @@ internal void SimulateLinkActivated(Uri uri) } #endif +#if UNITY_IOS && !UNITY_EDITOR + private void OnShortcutFocusChanged(bool focused) + { + if (focused) DeliverPendingShortcut(); + } + + private void DeliverPendingShortcut() + { + var uri = TryParse(AppShortcutsBridge.TakePendingLink()); + if (uri == null) + { + return; + } + + // Unlike a runtime link, a shortcut that arrives before anyone subscribed is kept for the first subscriber. + if (_onLinkActivated == null) + { + _pendingColdStartLink = uri; + return; + } + _onLinkActivated.Invoke(uri); + } +#endif + private void OnDeepLinkActivated(string url) { var parsed = TryParse(url); diff --git a/Runtime/Device/DeviceService.cs b/Runtime/Device/DeviceService.cs index 3010966..a9b8f6e 100644 --- a/Runtime/Device/DeviceService.cs +++ b/Runtime/Device/DeviceService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using GameLovers.MobileServices.Device.Internal; using UnityEngine; @@ -8,6 +10,14 @@ namespace GameLovers.MobileServices.Device /// public sealed class DeviceService : IDeviceService, IDisposable { + /// The most dynamic shortcuts both platforms reliably show. + internal const int MaxShortcuts = 4; + +#if UNITY_EDITOR + /// Editor simulator hook that stands in for the OS Settings page. + internal static Action EditorOpenAppSettingsOverride; +#endif + /// /// Controls whether the device screen stays awake. When true, sets /// Screen.sleepTimeout to SleepTimeout.NeverSleep; when false, @@ -19,8 +29,6 @@ public static bool KeepAwake set => Screen.sleepTimeout = value ? SleepTimeout.NeverSleep : SleepTimeout.SystemSetting; } - /// - public ISafeAreaService SafeArea { get; } /// public IBatteryService Battery { get; } /// @@ -31,54 +39,191 @@ public static bool KeepAwake public IAttService Att { get; } /// public IDeepLinkService DeepLink { get; } + /// + public IBackgroundTaskService BackgroundTasks { get; } public DeviceService() : this(BuildDefaults()) { } public DeviceService( - ISafeAreaService safeArea, IBatteryService battery, IIosAudioSessionService audioSession, IPermissionsService permissions, IAttService att, - IDeepLinkService deepLink) + IDeepLinkService deepLink, + IBackgroundTaskService backgroundTasks) { - SafeArea = safeArea; Battery = battery; AudioSession = audioSession; Permissions = permissions; Att = att; DeepLink = deepLink; + BackgroundTasks = backgroundTasks; } // Tuple-routed delegating ctor so the host-dependent children share one explicit host // instance constructed up-front, not separate accesses to the singleton during a // constructor chain (cleaner ownership signal in the umbrella's call stack). - private DeviceService((ISafeAreaService, IBatteryService, - IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) defaults) + private DeviceService((IBatteryService, + IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService, IBackgroundTaskService) defaults) : this(defaults.Item1, defaults.Item2, defaults.Item3, defaults.Item4, defaults.Item5, defaults.Item6) { } - private static (ISafeAreaService, IBatteryService, - IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService) BuildDefaults() + /// + /// Replaces the home-screen quick actions on the app icon. Tapping one opens the app and delivers the shortcut's + /// URI through , including as the cold-start link when the tap launched the app. + /// + /// + /// At most four shortcuts are kept; entries without an id, title or absolute URI, and repeated ids, are skipped + /// with a log message. Android needs API 25 or newer. + /// + public static void SetShortcuts(IReadOnlyList shortcuts) { - var host = DeviceServicesHost.Instance; - return ( - new SafeAreaService(host), - new BatteryService(host), - new IosAudioSessionService(), - new PermissionsService(), - new AttService(), - new DeepLinkService()); + AppShortcutsBridge.Set(NormalizeShortcuts(shortcuts)); + } + + /// + /// Removes every shortcut set with . + /// + public static void ClearShortcuts() + { + AppShortcutsBridge.Clear(); + } + + /// + /// Opens this application's page in the platform's store app: the App Store on iOS or Google Play on Android. + /// + /// The numeric App Store id, with or without an id prefix. Required on iOS. + /// The Android application id. Defaults to Application.identifier. + /// + /// Opens the iOS write-review sheet. Android has no review deep link, so the store page opens instead. + /// + public static void OpenStorePage(string iosAppId, string androidPackage = null, bool writeReview = false) + { + var url = BuildStoreUrl(Application.platform, iosAppId, androidPackage ?? Application.identifier, writeReview); + if (url == null) + { + Debug.LogWarning($"[GameLovers.MobileServices] OpenStorePage has no store page to open on {Application.platform} " + + "(iOS needs a numeric App Store id; Android needs a package name)."); + return; + } + +#if UNITY_EDITOR + Debug.Log($"[GameLovers.MobileServices] OpenStorePage would open {url}"); +#else + Application.OpenURL(url); +#endif + } + + /// + /// Opens this application's page in the operating system Settings, where the player can change a denied + /// permission. Opening Settings can move the app to the background. + /// + public static void OpenAppSettings() + { +#if UNITY_EDITOR + if (EditorOpenAppSettingsOverride != null) + { + EditorOpenAppSettingsOverride.Invoke(); + return; + } + Debug.Log("[GameLovers.MobileServices] OpenAppSettings() is a no-op in the Editor unless the Mobile Services simulator is engaged."); +#elif UNITY_IOS + Application.OpenURL("app-settings:"); +#elif UNITY_ANDROID + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var uriClass = new AndroidJavaClass("android.net.Uri"); + using var uri = uriClass.CallStatic("fromParts", "package", activity.Call("getPackageName"), (string)null); + using var intent = new AndroidJavaObject("android.content.Intent", "android.settings.APPLICATION_DETAILS_SETTINGS", uri); + activity.Call("startActivity", intent); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] OpenAppSettings failed: {exception.Message}"); + } +#endif } /// public void Dispose() { - (SafeArea as IDisposable)?.Dispose(); (Battery as IDisposable)?.Dispose(); (DeepLink as IDisposable)?.Dispose(); } + + /// Builds the store page link for a platform, or null when there is no store or identifier. + internal static string BuildStoreUrl(RuntimePlatform platform, string iosAppId, string androidPackage, bool writeReview) + { + switch (platform) + { + case RuntimePlatform.IPhonePlayer: + { + var id = (iosAppId ?? string.Empty).Trim(); + if (id.StartsWith("id", StringComparison.OrdinalIgnoreCase)) id = id.Substring(2); + if (id.Length == 0 || !id.All(character => character >= '0' && character <= '9')) return null; + return "https://apps.apple.com/app/id" + id + (writeReview ? "?action=write-review" : string.Empty); + } + case RuntimePlatform.Android: + { + var package = (androidPackage ?? string.Empty).Trim(); + return package.Length == 0 ? null : "https://play.google.com/store/apps/details?id=" + Uri.EscapeDataString(package); + } + default: + return null; + } + } + + /// Keeps the usable shortcuts in order, dropping incomplete, repeated and surplus ones. + internal static IReadOnlyList NormalizeShortcuts(IReadOnlyList shortcuts) + { + var result = new List(); + if (shortcuts == null) + { + return result; + } + + var ids = new HashSet(); + foreach (var shortcut in shortcuts) + { + if (shortcut == null || string.IsNullOrWhiteSpace(shortcut.Id) || string.IsNullOrWhiteSpace(shortcut.Title) || + !Uri.TryCreate(shortcut.Uri, UriKind.Absolute, out _)) + { + Debug.LogWarning($"[GameLovers.MobileServices] Skipped shortcut '{shortcut?.Id}': it needs an id, a title and an absolute URI."); + continue; + } + + if (!ids.Add(shortcut.Id)) + { + Debug.LogWarning($"[GameLovers.MobileServices] Skipped shortcut '{shortcut.Id}': the id is already used."); + continue; + } + + if (result.Count == MaxShortcuts) + { + Debug.LogWarning($"[GameLovers.MobileServices] Skipped shortcut '{shortcut.Id}': at most {MaxShortcuts} shortcuts are supported."); + continue; + } + + result.Add(shortcut); + } + return result; + } + + private static (IBatteryService, + IIosAudioSessionService, IPermissionsService, IAttService, IDeepLinkService, IBackgroundTaskService) BuildDefaults() + { + var host = DeviceServicesHost.Instance; + return ( + new BatteryService(host), + new IosAudioSessionService(), + new PermissionsService(), + new AttService(), + new DeepLinkService(), + new BackgroundTaskService()); + } } } diff --git a/Runtime/Device/IDeviceService.cs b/Runtime/Device/IDeviceService.cs index 436a841..ff0d719 100644 --- a/Runtime/Device/IDeviceService.cs +++ b/Runtime/Device/IDeviceService.cs @@ -9,9 +9,6 @@ namespace GameLovers.MobileServices.Device /// public interface IDeviceService { - /// Display safe-area events (notch, dynamic island, orientation). - ISafeAreaService SafeArea { get; } - /// Battery level / status / low-power-mode awareness. IBatteryService Battery { get; } @@ -26,5 +23,8 @@ public interface IDeviceService /// OS deep link delivery with cold-start link queueing. IDeepLinkService DeepLink { get; } + + /// Extra execution time when the app moves to the background (iOS; pass-through on Android). + IBackgroundTaskService BackgroundTasks { get; } } } diff --git a/Runtime/Device/Internal/AndroidApiLevel.cs b/Runtime/Device/Internal/AndroidApiLevel.cs new file mode 100644 index 0000000..ac3d041 --- /dev/null +++ b/Runtime/Device/Internal/AndroidApiLevel.cs @@ -0,0 +1,35 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device.Internal +{ + /// Reads the running Android API level once; reports 0 when the query is unavailable. + internal static class AndroidApiLevel + { +#if UNITY_ANDROID && !UNITY_EDITOR + private static int _current = -1; + + /// The device API level, or 0 when it cannot be read. + public static int Current + { + get + { + if (_current >= 0) return _current; + try + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + _current = version.GetStatic("SDK_INT"); + } + catch + { + _current = 0; + } + return _current; + } + } +#else + /// The device API level, which is always 0 away from an Android device. + public static int Current => 0; +#endif + } +} diff --git a/Runtime/Device/Internal/AndroidApiLevel.cs.meta b/Runtime/Device/Internal/AndroidApiLevel.cs.meta new file mode 100644 index 0000000..54c23c6 --- /dev/null +++ b/Runtime/Device/Internal/AndroidApiLevel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 926949cd9028c4988af253fabe91235d \ No newline at end of file diff --git a/Runtime/Device/Internal/DeviceServicesHost.cs b/Runtime/Device/Internal/DeviceServicesHost.cs index d20dbff..c0eaa48 100644 --- a/Runtime/Device/Internal/DeviceServicesHost.cs +++ b/Runtime/Device/Internal/DeviceServicesHost.cs @@ -5,7 +5,7 @@ namespace GameLovers.MobileServices.Device.Internal { /// - /// Internal MonoBehaviour shared by all event-driven Device services (SafeArea, Battery). + /// Internal MonoBehaviour shared by all event-driven Device services (Battery). /// 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. /// diff --git a/Runtime/Device/Permissions/IPermissionsService.cs b/Runtime/Device/Permissions/IPermissionsService.cs index 7e57da3..ce353d0 100644 --- a/Runtime/Device/Permissions/IPermissionsService.cs +++ b/Runtime/Device/Permissions/IPermissionsService.cs @@ -23,16 +23,22 @@ public enum AppPermission /// public enum PermissionStatus { - NotDetermined, - Denied, - Granted, - Restricted, + NotDetermined, // 0 + Denied, // 1 + Granted, // 2 + Restricted, // 3 + Limited, // 4: partial access (selected photos, provisional notifications, approximate or when-in-use-only location) } /// /// Unified iOS+Android runtime-permissions service. /// /// + /// Treat and as usable access. + /// A permission usually cannot be requested again: explain why it is + /// needed and send the player to . + /// + /// /// Uses rather than UniTask to avoid pulling in a new package dependency. /// public interface IPermissionsService diff --git a/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs b/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs new file mode 100644 index 0000000..ef859e8 --- /dev/null +++ b/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device.Internal +{ + /// + /// Platform-neutral Android runtime-permission rules: which manifest permissions an + /// needs at a given API level and how their grant states fold into one . + /// + internal static class AndroidPermissionPolicy + { + /// Android 10, which added scoped storage and the separate background location permission. + public const int ApiLevelQ = 29; + /// Android 13, which introduced the notification and granular media permissions. + public const int ApiLevelTiramisu = 33; + /// Android 14, which introduced partial photo access. + public const int ApiLevelUpsideDownCake = 34; + + /// Manifest permission CAMERA. + public const string Camera = "android.permission.CAMERA"; + /// Manifest permission RECORD_AUDIO. + public const string RecordAudio = "android.permission.RECORD_AUDIO"; + /// Manifest permission ACCESS_FINE_LOCATION. + public const string FineLocation = "android.permission.ACCESS_FINE_LOCATION"; + /// Manifest permission ACCESS_COARSE_LOCATION. + public const string CoarseLocation = "android.permission.ACCESS_COARSE_LOCATION"; + /// Manifest permission ACCESS_BACKGROUND_LOCATION. + public const string BackgroundLocation = "android.permission.ACCESS_BACKGROUND_LOCATION"; + /// Manifest permission READ_MEDIA_IMAGES. + public const string ReadMediaImages = "android.permission.READ_MEDIA_IMAGES"; + /// Manifest permission READ_MEDIA_VISUAL_USER_SELECTED. + public const string ReadMediaVisualUserSelected = "android.permission.READ_MEDIA_VISUAL_USER_SELECTED"; + /// Manifest permission READ_EXTERNAL_STORAGE. + public const string ReadExternalStorage = "android.permission.READ_EXTERNAL_STORAGE"; + /// Manifest permission WRITE_EXTERNAL_STORAGE. + public const string WriteExternalStorage = "android.permission.WRITE_EXTERNAL_STORAGE"; + /// Manifest permission POST_NOTIFICATIONS. + public const string PostNotifications = "android.permission.POST_NOTIFICATIONS"; + + /// + /// The manifest permissions to request in one system dialog. Empty when the OS has nothing to ask + /// (for example saving to the gallery on API 29+, or notifications below API 33). + /// + public static IReadOnlyList GetForegroundRequest(AppPermission permission, int apiLevel) + { + switch (permission) + { + case AppPermission.Camera: + return new[] { Camera }; + case AppPermission.Microphone: + return new[] { RecordAudio }; + case AppPermission.LocationWhenInUse: + case AppPermission.LocationAlways: + // Android 12+ ignores a precise-only request, so both are always asked together. + return new[] { FineLocation, CoarseLocation }; + case AppPermission.PhotoLibrary: + if (apiLevel >= ApiLevelUpsideDownCake) return new[] { ReadMediaImages, ReadMediaVisualUserSelected }; + return new[] { apiLevel >= ApiLevelTiramisu ? ReadMediaImages : ReadExternalStorage }; + case AppPermission.PhotoLibraryAddOnly: + return apiLevel >= ApiLevelQ ? Array.Empty() : new[] { WriteExternalStorage }; + case AppPermission.Notifications: + return apiLevel >= ApiLevelTiramisu ? new[] { PostNotifications } : Array.Empty(); + default: + return Array.Empty(); + } + } + + /// + /// Whether needs a second, separate request after the foreground one. + /// Android requires background location to be requested on its own, after foreground access is granted. + /// + public static bool NeedsBackgroundRequest(AppPermission permission, int apiLevel) => + permission == AppPermission.LocationAlways && apiLevel >= ApiLevelQ; + + /// + /// Folds the individual grant states into one status; a refusal is reported once the dialog was shown or the OS + /// rationale flag is set. + /// + public static PermissionStatus Resolve( + AppPermission permission, + int apiLevel, + Func isGranted, + bool wasRequested, + bool showRationale) + { + var denied = wasRequested || showRationale ? PermissionStatus.Denied : PermissionStatus.NotDetermined; + switch (permission) + { + case AppPermission.LocationWhenInUse: + case AppPermission.LocationAlways: + { + var fine = isGranted(FineLocation); + if (!fine && !isGranted(CoarseLocation)) return denied; + if (!fine) return PermissionStatus.Limited; + var needsBackground = permission == AppPermission.LocationAlways && apiLevel >= ApiLevelQ; + return needsBackground && !isGranted(BackgroundLocation) ? PermissionStatus.Limited : PermissionStatus.Granted; + } + case AppPermission.PhotoLibrary: + { + var full = isGranted(apiLevel >= ApiLevelTiramisu ? ReadMediaImages : ReadExternalStorage); + if (full) return PermissionStatus.Granted; + if (apiLevel >= ApiLevelUpsideDownCake && isGranted(ReadMediaVisualUserSelected)) return PermissionStatus.Limited; + return denied; + } + case AppPermission.PhotoLibraryAddOnly: + if (apiLevel >= ApiLevelQ) return PermissionStatus.Granted; + return isGranted(WriteExternalStorage) ? PermissionStatus.Granted : denied; + case AppPermission.Notifications: + if (apiLevel < ApiLevelTiramisu) return PermissionStatus.NotDetermined; + return isGranted(PostNotifications) ? PermissionStatus.Granted : denied; + default: + { + var request = GetForegroundRequest(permission, apiLevel); + if (request.Count == 0) return PermissionStatus.NotDetermined; + foreach (var manifestPermission in request) + { + if (!isGranted(manifestPermission)) return denied; + } + return PermissionStatus.Granted; + } + } + } + + /// + /// Status of notifications below API 33, where no runtime permission exists and the only signal is the + /// user-controlled "notifications enabled" switch. + /// + public static PermissionStatus ResolveLegacyNotifications(bool areNotificationsEnabled) => + areNotificationsEnabled ? PermissionStatus.Granted : PermissionStatus.Denied; + } +} diff --git a/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs.meta b/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs.meta new file mode 100644 index 0000000..d470366 --- /dev/null +++ b/Runtime/Device/Permissions/Internal/AndroidPermissionPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 48a8e78d9eeef4fabbbb8d394c231b14 \ No newline at end of file diff --git a/Runtime/Device/Permissions/PermissionsService.cs b/Runtime/Device/Permissions/PermissionsService.cs index b69dfd2..66453e1 100644 --- a/Runtime/Device/Permissions/PermissionsService.cs +++ b/Runtime/Device/Permissions/PermissionsService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using GameLovers.MobileServices.Device.Internal; using UnityEngine; @@ -15,6 +16,11 @@ namespace GameLovers.MobileServices.Device /// public sealed class PermissionsService : IPermissionsService { +#if UNITY_ANDROID && !UNITY_EDITOR + private const string RequestedKeyPrefix = nameof(PermissionsService) + ".Requested."; + private static readonly SemaphoreSlim _androidRequestGate = new SemaphoreSlim(1, 1); +#endif + #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); @@ -88,54 +94,107 @@ public Task RequestAsync(AppPermission permission) } #if UNITY_ANDROID && !UNITY_EDITOR - private static string AndroidManifestPermission(AppPermission permission) + private static PermissionStatus CheckAndroid(AppPermission permission) { - return permission switch + var apiLevel = AndroidApiLevel.Current; + if (permission == AppPermission.Notifications && apiLevel < AndroidPermissionPolicy.ApiLevelTiramisu) { - 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 - }; + return AndroidPermissionPolicy.ResolveLegacyNotifications(AreNotificationsEnabled(apiLevel)); + } + + return AndroidPermissionPolicy.Resolve( + permission, + apiLevel, + Permission.HasUserAuthorizedPermission, + WasRequested(permission), + ShouldShowRationale(permission, apiLevel)); } - private static PermissionStatus CheckAndroid(AppPermission permission) + private static async Task RequestAndroidAsync(AppPermission permission) { - var manifestId = AndroidManifestPermission(permission); - if (string.IsNullOrEmpty(manifestId)) + var apiLevel = AndroidApiLevel.Current; + var current = CheckAndroid(permission); + var legacyNotifications = permission == AppPermission.Notifications && apiLevel < AndroidPermissionPolicy.ApiLevelTiramisu; + if (current == PermissionStatus.Granted || legacyNotifications) { - return PermissionStatus.NotDetermined; + return current; } - return Permission.HasUserAuthorizedPermission(manifestId) - ? PermissionStatus.Granted - : PermissionStatus.NotDetermined; - } - private static Task RequestAndroidAsync(AppPermission permission) - { - var manifestId = AndroidManifestPermission(permission); - if (string.IsNullOrEmpty(manifestId)) + // Android shows one system dialog at a time; a concurrent request would be dropped without a callback. + await _androidRequestGate.WaitAsync(); + try { - return Task.FromResult(PermissionStatus.NotDetermined); + var missing = new List(); + foreach (var manifestPermission in AndroidPermissionPolicy.GetForegroundRequest(permission, apiLevel)) + { + if (!Permission.HasUserAuthorizedPermission(manifestPermission)) missing.Add(manifestPermission); + } + if (missing.Count > 0) + { + await RequestManifestPermissionsAsync(missing.ToArray()); + } + + var hasForegroundLocation = Permission.HasUserAuthorizedPermission(AndroidPermissionPolicy.FineLocation) || + Permission.HasUserAuthorizedPermission(AndroidPermissionPolicy.CoarseLocation); + if (AndroidPermissionPolicy.NeedsBackgroundRequest(permission, apiLevel) && hasForegroundLocation && + !Permission.HasUserAuthorizedPermission(AndroidPermissionPolicy.BackgroundLocation)) + { + await RequestManifestPermissionsAsync(new[] { AndroidPermissionPolicy.BackgroundLocation }); + } + + PlayerPrefs.SetInt(RequestedKeyPrefix + permission, 1); + PlayerPrefs.Save(); + return CheckAndroid(permission); } + finally + { + _androidRequestGate.Release(); + } + } - if (Permission.HasUserAuthorizedPermission(manifestId)) + private static Task RequestManifestPermissionsAsync(string[] manifestPermissions) + { + var completion = new TaskCompletionSource(); + var remaining = manifestPermissions.Length; + void OnAnswered(string _) { - return Task.FromResult(PermissionStatus.Granted); + if (--remaining == 0) completion.TrySetResult(true); } - 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); + callbacks.PermissionGranted += OnAnswered; + callbacks.PermissionDenied += OnAnswered; + Permission.RequestUserPermissions(manifestPermissions, callbacks); + return completion.Task; + } - Permission.RequestUserPermission(manifestId, callbacks); - return tcs.Task; + private static bool WasRequested(AppPermission permission) => PlayerPrefs.GetInt(RequestedKeyPrefix + permission, 0) == 1; + + private static bool ShouldShowRationale(AppPermission permission, int apiLevel) + { + foreach (var manifestPermission in AndroidPermissionPolicy.GetForegroundRequest(permission, apiLevel)) + { + if (Permission.ShouldShowRequestPermissionRationale(manifestPermission)) return true; + } + return false; + } + + private static bool AreNotificationsEnabled(int apiLevel) + { + // NotificationManager.areNotificationsEnabled needs API 24; older devices cannot disable them per app. + if (apiLevel < 24) return true; + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var manager = activity.Call("getSystemService", "notification"); + return manager.Call("areNotificationsEnabled"); + } + catch (Exception exception) + { + Debug.LogWarning($"[GameLovers.MobileServices] Could not read the notifications-enabled state: {exception.Message}"); + return true; + } } #endif } diff --git a/Runtime/Device/Shortcuts.meta b/Runtime/Device/Shortcuts.meta new file mode 100644 index 0000000..bd4bb91 --- /dev/null +++ b/Runtime/Device/Shortcuts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b4af5aabfba8f4b4e931aa463c461760 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Device/Shortcuts/AppShortcut.cs b/Runtime/Device/Shortcuts/AppShortcut.cs new file mode 100644 index 0000000..e9ade5c --- /dev/null +++ b/Runtime/Device/Shortcuts/AppShortcut.cs @@ -0,0 +1,30 @@ +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device +{ + /// + /// One home-screen quick action: the long-press menu entry on the app icon. Tapping it opens the app with + /// delivered through . + /// + public sealed class AppShortcut + { + /// A stable id, unique among the shortcuts the game sets. + public string Id { get; } + + /// The label shown in the menu. + public string Title { get; } + + /// The absolute deep-link URI opened on tap, for example mygame://daily. + public string Uri { get; } + + /// An optional second line: the subtitle on iOS, the long label on Android. + public string Subtitle { get; } + + public AppShortcut(string id, string title, string uri, string subtitle = null) + { + Id = id; + Title = title; + Uri = uri; + Subtitle = subtitle; + } + } +} diff --git a/Runtime/Device/Shortcuts/AppShortcut.cs.meta b/Runtime/Device/Shortcuts/AppShortcut.cs.meta new file mode 100644 index 0000000..dc6ab1d --- /dev/null +++ b/Runtime/Device/Shortcuts/AppShortcut.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09dd7d43c2a454e5ebc3b07a6aaaf477 \ No newline at end of file diff --git a/Runtime/Device/Shortcuts/AppShortcutsBridge.cs b/Runtime/Device/Shortcuts/AppShortcutsBridge.cs new file mode 100644 index 0000000..e1fa5f6 --- /dev/null +++ b/Runtime/Device/Shortcuts/AppShortcutsBridge.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +#if UNITY_IOS && !UNITY_EDITOR +using System.Runtime.InteropServices; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.Device.Internal +{ + /// Sets and clears the OS shortcuts and, on iOS, hands taps to the deep-link service. + internal static class AppShortcutsBridge + { +#if UNITY_IOS && !UNITY_EDITOR + [DllImport("__Internal")] private static extern void _GameLoversShortcutsSet(string json); + [DllImport("__Internal")] private static extern void _GameLoversShortcutsClear(); + [DllImport("__Internal")] private static extern IntPtr _GameLoversShortcutTakePending(); +#endif + + /// Publishes the shortcuts to the OS, replacing the previous set. + public static void Set(IReadOnlyList shortcuts) + { +#if UNITY_EDITOR + Debug.Log($"[GameLovers.MobileServices] SetShortcuts is not available in the editor ({shortcuts.Count} shortcut(s)); " + + "drive taps with EditorPlatformSimulator.SimulateDeepLink."); +#elif UNITY_IOS + var items = new ShortcutItem[shortcuts.Count]; + for (var i = 0; i < items.Length; i++) + { + items[i] = new ShortcutItem { id = shortcuts[i].Id, title = shortcuts[i].Title, subtitle = shortcuts[i].Subtitle ?? string.Empty, uri = shortcuts[i].Uri }; + } + _GameLoversShortcutsSet(JsonUtility.ToJson(new ShortcutItems { items = items })); +#elif UNITY_ANDROID + SetAndroid(shortcuts); +#else + Debug.LogWarning("[GameLovers.MobileServices] Shortcuts are only available on iOS and Android."); +#endif + } + + /// Removes every shortcut the app published. + public static void Clear() + { +#if UNITY_EDITOR + Debug.Log("[GameLovers.MobileServices] ClearShortcuts is not available in the editor."); +#elif UNITY_IOS + _GameLoversShortcutsClear(); +#elif UNITY_ANDROID + CallAndroid("clear", null); +#endif + } + + /// Takes the deep-link URI of a shortcut tapped since the last call, or null. iOS only. + public static string TakePendingLink() + { +#if UNITY_IOS && !UNITY_EDITOR + var pointer = _GameLoversShortcutTakePending(); + return pointer == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(pointer); +#else + return null; +#endif + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static void SetAndroid(IReadOnlyList shortcuts) + { + if (AndroidApiLevel.Current < 25) + { + Debug.LogWarning("[GameLovers.MobileServices] Shortcuts need Android 7.1 (API 25) or newer."); + return; + } + + var ids = new string[shortcuts.Count]; + var titles = new string[shortcuts.Count]; + var subtitles = new string[shortcuts.Count]; + var uris = new string[shortcuts.Count]; + for (var i = 0; i < ids.Length; i++) + { + ids[i] = shortcuts[i].Id; + titles[i] = shortcuts[i].Title; + subtitles[i] = shortcuts[i].Subtitle ?? string.Empty; + uris[i] = shortcuts[i].Uri; + } + CallAndroid("set", new object[] { ids, titles, subtitles, uris }); + } + + private static void CallAndroid(string method, object[] arguments) + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var shortcuts = new AndroidJavaClass("com.gamelovers.mobileservices.GameLoversShortcuts"); + var all = new object[(arguments?.Length ?? 0) + 1]; + all[0] = activity; + arguments?.CopyTo(all, 1); + shortcuts.CallStatic(method, all); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] Shortcuts failed: {exception.Message}"); + } + } +#endif + +#if UNITY_IOS && !UNITY_EDITOR + [Serializable] + private sealed class ShortcutItems + { + public ShortcutItem[] items; + } + + [Serializable] + private sealed class ShortcutItem + { + public string id; + public string title; + public string subtitle; + public string uri; + } +#endif + } +} diff --git a/Runtime/Device/Shortcuts/AppShortcutsBridge.cs.meta b/Runtime/Device/Shortcuts/AppShortcutsBridge.cs.meta new file mode 100644 index 0000000..924ae19 --- /dev/null +++ b/Runtime/Device/Shortcuts/AppShortcutsBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 507bd6cf494f840f7a86ff3673c05638 \ No newline at end of file diff --git a/Runtime/Device/State/ISafeAreaService.cs b/Runtime/Device/State/ISafeAreaService.cs deleted file mode 100644 index ee726f2..0000000 --- a/Runtime/Device/State/ISafeAreaService.cs +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 133933e..0000000 --- a/Runtime/Device/State/ISafeAreaService.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 322efff88ac064790a260aaaea5a4501 \ No newline at end of file diff --git a/Runtime/Device/State/SafeAreaContainer.cs b/Runtime/Device/State/SafeAreaContainer.cs deleted file mode 100644 index 1c3b1e9..0000000 --- a/Runtime/Device/State/SafeAreaContainer.cs +++ /dev/null @@ -1,89 +0,0 @@ -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. - /// - [UxmlElement] - public sealed partial class SafeAreaContainer : VisualElement - { - private ISafeAreaService _safeAreaService; - - public SafeAreaContainer() - { - RegisterCallback(_ => Apply()); - } - - 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(); - } - - /// Converts a screen-pixel inset into the active UI Toolkit panel coordinate system. - internal static float ScreenPixelsToPanelUnits(float screenPixels, float scaledPixelsPerPoint) - { - if (scaledPixelsPerPoint <= 0f) - { - return screenPixels; - } - - return screenPixels / scaledPixelsPerPoint; - } - - 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; - } - - // Screen.safeArea is expressed in screen pixels, while UI Toolkit styles use the - // panel's coordinate system. scaledPixelsPerPoint is Unity's authoritative conversion, - // including the PanelSettings Scale With Screen Size factor. - var scaledPixelsPerPoint = panel?.scaledPixelsPerPoint ?? 1f; - var left = ScreenPixelsToPanelUnits(safeArea.xMin, scaledPixelsPerPoint); - var right = ScreenPixelsToPanelUnits(screenWidth - safeArea.xMax, scaledPixelsPerPoint); - var top = ScreenPixelsToPanelUnits(screenHeight - safeArea.yMax, scaledPixelsPerPoint); - var bottom = ScreenPixelsToPanelUnits(safeArea.yMin, scaledPixelsPerPoint); - - 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 deleted file mode 100644 index 060ef75..0000000 --- a/Runtime/Device/State/SafeAreaContainer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e6962b99fd39d479fbd1d52b08f5b7ed \ No newline at end of file diff --git a/Runtime/Device/State/SafeAreaService.cs b/Runtime/Device/State/SafeAreaService.cs deleted file mode 100644 index 88a09ec..0000000 --- a/Runtime/Device/State/SafeAreaService.cs +++ /dev/null @@ -1,75 +0,0 @@ -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; - -#if UNITY_EDITOR - // Editor-only simulator override. When set, the LateUpdate poll reports this rect - // instead of Screen.safeArea so the Device Simulator panel can drive notch/dynamic-island previews. - internal static Rect? EditorSafeAreaOverride; -#endif - - /// - public Rect SafeArea => _lastSafeArea; - - /// - public event Action OnSafeAreaChanged; - - public SafeAreaService() : this(DeviceServicesHost.Instance) { } - - 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); - } - -#if UNITY_EDITOR - /// - /// Editor-only simulator hook. Forces an immediate diff against - /// so the Device Simulator panel's notch-inset affordance - /// surfaces the change without waiting for the next LateUpdate tick. - /// - internal void SimulateSafeAreaChanged() - { - Tick(); - } -#endif - - private void Tick() - { -#if UNITY_EDITOR - var current = EditorSafeAreaOverride ?? Screen.safeArea; -#else - var current = Screen.safeArea; -#endif - var resolution = new Vector2Int(Screen.width, Screen.height); - - if (current == _lastSafeArea && resolution == _lastResolution) - { - return; - } - - _lastSafeArea = current; - _lastResolution = resolution; - OnSafeAreaChanged?.Invoke(current); - } - } -} diff --git a/Runtime/Device/State/SafeAreaService.cs.meta b/Runtime/Device/State/SafeAreaService.cs.meta deleted file mode 100644 index 832b65f..0000000 --- a/Runtime/Device/State/SafeAreaService.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 72d96de654ed341f78d86327d9eb3270 \ No newline at end of file diff --git a/Runtime/GamePlatform.meta b/Runtime/GamePlatform.meta new file mode 100644 index 0000000..5cd3619 --- /dev/null +++ b/Runtime/GamePlatform.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 37eff83b835cb45f085f2cdb123b9d7f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GamePlatform/CloudSave.meta b/Runtime/GamePlatform/CloudSave.meta new file mode 100644 index 0000000..77409f9 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6d9942bb45e6f4205bc0e7f4682643a6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GamePlatform/CloudSave/CloudSaveService.cs b/Runtime/GamePlatform/CloudSave/CloudSaveService.cs new file mode 100644 index 0000000..68db4ac --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/CloudSaveService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using GameLovers.MobileServices.GamePlatform.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform +{ + /// + public sealed class CloudSaveService : ICloudSaveService + { + /// The largest save accepted: the Play Games snapshot limit, applied on both platforms. + public const int MaxDataBytes = 3 * 1024 * 1024; + + private static readonly Regex _slotName = new Regex("^[A-Za-z0-9._~-]{1,100}$", RegexOptions.Compiled); + + private readonly Func _isAuthenticated; + private readonly ICloudSaveBackend _backend; + + /// + public bool IsAvailable => _isAuthenticated() && _backend.IsAvailable; + + /// + public CloudSaveConflictPolicy ConflictPolicy { get; set; } = CloudSaveConflictPolicy.MostRecent; + + /// + public Func ConflictResolver { get; set; } + + internal CloudSaveService(Func isAuthenticated, ICloudSaveBackend backend) + { + _isAuthenticated = isAuthenticated; + _backend = backend; + } + + /// + public async Awaitable> ListAsync() + { + ThrowIfUnavailable(); + return await _backend.ListAsync(); + } + + /// + public async Awaitable LoadAsync(string slot) + { + ValidateSlot(slot); + ThrowIfUnavailable(); + + var versions = await _backend.LoadVersionsAsync(slot); + if (versions == null || versions.Count == 0) + { + return null; + } + + if (versions.Count == 1) + { + return versions[0].Data; + } + + var chosen = Choose(slot, versions); + await _backend.ResolveAsync(slot, chosen); + return chosen.Data; + } + + /// + public async Awaitable SaveAsync(string slot, byte[] data, string description = null) + { + ValidateSlot(slot); + if (data == null) throw new ArgumentNullException(nameof(data)); + if (data.Length > MaxDataBytes) + { + throw new ArgumentException($"A cloud save is at most {MaxDataBytes} bytes; got {data.Length}.", nameof(data)); + } + ThrowIfUnavailable(); + + await _backend.SaveAsync(slot, data, description); + } + + /// + public async Awaitable DeleteAsync(string slot) + { + ValidateSlot(slot); + ThrowIfUnavailable(); + + await _backend.DeleteAsync(slot); + } + + private static void ValidateSlot(string slot) + { + if (slot == null) throw new ArgumentNullException(nameof(slot)); + if (!_slotName.IsMatch(slot)) + { + throw new ArgumentException($"'{slot}' is not a valid slot name; use 1 to 100 letters, digits, '-', '.', '_' or '~'.", nameof(slot)); + } + } + + private CloudSaveVersion Choose(string slot, IReadOnlyList versions) + { + var newestFirst = new List(versions); + newestFirst.Sort((left, right) => right.ModifiedUtc.CompareTo(left.ModifiedUtc)); + + if (ConflictPolicy == CloudSaveConflictPolicy.MostRecent) + { + return newestFirst[0]; + } + + var resolver = ConflictResolver; + if (resolver == null) + { + throw new CloudSaveException(CloudSaveFailure.Conflict, + $"Slot '{slot}' has conflicting versions and the Manual policy has no {nameof(ConflictResolver)}."); + } + + return resolver(new CloudSaveConflict(slot, newestFirst)) + ?? throw new CloudSaveException(CloudSaveFailure.Conflict, $"The conflict resolver for slot '{slot}' returned no version."); + } + + private void ThrowIfUnavailable() + { + if (!_isAuthenticated()) + { + throw new CloudSaveException(CloudSaveFailure.NotAuthenticated, "Cloud saves need a signed-in player; call IGamePlatformServices.AuthenticateAsync first."); + } + + if (!_backend.IsAvailable) + { + throw new CloudSaveException(CloudSaveFailure.CloudUnavailable, "The platform's cloud storage is not available (iCloud Drive off or Play Games saved games unavailable)."); + } + } + } +} diff --git a/Runtime/GamePlatform/CloudSave/CloudSaveService.cs.meta b/Runtime/GamePlatform/CloudSave/CloudSaveService.cs.meta new file mode 100644 index 0000000..2a1df08 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/CloudSaveService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d93f560674cb4aeeabb864ed692885a \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs b/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs new file mode 100644 index 0000000..9eb3ed2 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform +{ + /// + /// Why a cloud save operation failed. + /// + public enum CloudSaveFailure + { + NotAuthenticated, // No player is signed in to the platform's game service. + CloudUnavailable, // The platform's cloud storage is off (iCloud Drive disabled, Play Games saved games unavailable). + Conflict, // Versions of a slot conflict and the Manual policy has no resolver. + Io // The platform failed to read or write the save. + } + + /// + /// How a cloud save conflict between devices is decided. + /// + public enum CloudSaveConflictPolicy + { + MostRecent, // Keep the most recently modified version. + Manual // Ask the conflict resolver. + } + + /// + /// Thrown by when the platform cannot complete an operation. + /// + public sealed class CloudSaveException : Exception + { + /// The category of failure. + public CloudSaveFailure Reason { get; } + + public CloudSaveException(CloudSaveFailure reason, string message, Exception inner = null) : base(message, inner) + { + Reason = reason; + } + } + + /// + /// One cloud save slot as listed by . + /// + public sealed class CloudSaveSlot + { + /// The slot name. + public string Name { get; } + + /// The description saved with it. Game Center has none, so it is empty on iOS. + public string Description { get; } + + /// When it was last saved, in UTC. + public DateTime ModifiedUtc { get; } + + /// The device that saved it, when the platform reports one. + public string DeviceName { get; } + + public CloudSaveSlot(string name, string description, DateTime modifiedUtc, string deviceName) + { + Name = name; + Description = description; + ModifiedUtc = modifiedUtc; + DeviceName = deviceName; + } + } + + /// + /// One stored version of a slot: its bytes and who saved it. + /// + public sealed class CloudSaveVersion + { + /// The saved bytes. + public byte[] Data { get; } + + /// The description saved with it, or empty. + public string Description { get; } + + /// When it was saved, in UTC. + public DateTime ModifiedUtc { get; } + + /// The device that saved it, when known. + public string DeviceName { get; } + + public CloudSaveVersion(byte[] data, string description, DateTime modifiedUtc, string deviceName) + { + Data = data; + Description = description; + ModifiedUtc = modifiedUtc; + DeviceName = deviceName; + } + } + + /// + /// Two or more versions of one slot that devices saved independently. + /// + public sealed class CloudSaveConflict + { + /// The slot in conflict. + public string SlotName { get; } + + /// Every conflicting version, most recently modified first. Always at least two. + public IReadOnlyList Versions { get; } + + public CloudSaveConflict(string slotName, IReadOnlyList versions) + { + SlotName = slotName; + Versions = versions; + } + } + + /// + /// Slot-based cloud saves on the platform's own storage: Game Center saved games (iCloud) on iOS and Google Play + /// Games saved games on Android. Reached through . + /// + /// + /// Every operation needs a signed-in player and available cloud storage, and otherwise throws a + /// . Slot names use letters, digits, -, ., _ and ~, 1 to 100 + /// characters, and a save is at most bytes. + /// + public interface ICloudSaveService + { + /// True when a player is signed in and the platform's cloud storage is available. + bool IsAvailable { get; } + + /// + /// How a conflict between devices is decided. Defaults to . + /// + CloudSaveConflictPolicy ConflictPolicy { get; set; } + + /// + /// Picks the version to keep when is . + /// Return one of the conflict's versions or a new one built from merged data. + /// + Func ConflictResolver { get; set; } + + /// + /// Lists the saved slots. + /// + Awaitable> ListAsync(); + + /// + /// Reads a slot, resolving a conflict by first. + /// + /// The slot name. + /// The saved bytes, or null when the slot does not exist. + Awaitable LoadAsync(string slot); + + /// + /// Writes a slot, replacing its previous content. On iOS the write continues if the app is backgrounded. + /// + /// The slot name. + /// The bytes to save. + /// An optional description; Game Center ignores it. + Awaitable SaveAsync(string slot, byte[] data, string description = null); + + /// + /// Deletes a slot. Deleting a slot that does not exist does nothing. + /// + Awaitable DeleteAsync(string slot); + } +} diff --git a/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs.meta b/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs.meta new file mode 100644 index 0000000..3265c47 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/ICloudSaveService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d383d956723c64cf7b3f0d9c7ab1daa9 \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal.meta b/Runtime/GamePlatform/CloudSave/Internal.meta new file mode 100644 index 0000000..37f320f --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3d5a609da8af43188ccffb034ad52af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs b/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs new file mode 100644 index 0000000..cbbd9b2 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs @@ -0,0 +1,266 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// + /// Google Play Games saved-games (snapshots) backend. Like the sign-in backend it uses JNI so the package compiles + /// without the SDK. + /// + internal sealed class AndroidCloudSaveBackend : ICloudSaveBackend + { + private const string SnapshotsClientClass = "com.google.android.gms.games.SnapshotsClient"; + + private readonly Dictionary _conflicts = new Dictionary(); + + /// + public bool IsAvailable => true; + + /// + public Awaitable> ListAsync() + { + var completion = new AwaitableCompletionSource>(); + Guard(completion, () => ListMetadata(completion, metadata => + { + var slots = new List(); + foreach (var item in metadata) slots.Add(ToSlot(item)); + completion.TrySetResult(slots); + })); + return NativeCompletion.ToMainThread(completion); + } + + /// + public Awaitable> LoadVersionsAsync(string slot) + { + var completion = new AwaitableCompletionSource>(); + Guard(completion, () => ListMetadata(completion, metadata => + { + var match = Find(metadata, slot); + if (match == null) + { + completion.TrySetResult(Array.Empty()); + return; + } + + var client = Client(); + AndroidTaskListeners.Listen(client.Call("open", match, ResolutionPolicy("RESOLUTION_POLICY_MANUAL")), task => + Guard(completion, () => OnOpened(client, slot, task, completion))); + })); + return NativeCompletion.ToMainThread(completion); + } + + /// + public Awaitable ResolveAsync(string slot, CloudSaveVersion chosen) + { + var completion = new AwaitableCompletionSource(); + Guard(completion, () => + { + PendingConflict pending; + lock (_conflicts) + { + if (!_conflicts.TryGetValue(slot, out pending)) throw new CloudSaveException(CloudSaveFailure.Io, $"Slot '{slot}' has no open conflict to resolve."); + _conflicts.Remove(slot); + } + + using var contents = pending.Snapshot.Call("getSnapshotContents"); + if (!contents.Call("writeBytes", chosen.Data)) throw new CloudSaveException(CloudSaveFailure.Io, $"Could not write the resolved data for slot '{slot}'."); + + var client = Client(); + AndroidTaskListeners.Listen(client.Call("resolveConflict", pending.ConflictId, pending.Snapshot), task => + Guard(completion, () => + { + if (!task.Call("isSuccessful")) throw Failure(task); + using var result = task.Call("getResult"); + if (result.Call("isConflict")) throw new CloudSaveException(CloudSaveFailure.Conflict, $"Slot '{slot}' conflicted again while resolving."); + Discard(client, result.Call("getData")); + completion.TrySetResult(true); + })); + }); + return Void(NativeCompletion.ToMainThread(completion)); + } + + /// + public Awaitable SaveAsync(string slot, byte[] data, string description) + { + var completion = new AwaitableCompletionSource(); + Guard(completion, () => + { + var client = Client(); + AndroidTaskListeners.Listen(client.Call("open", slot, true, ResolutionPolicy("RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED")), task => + Guard(completion, () => + { + if (!task.Call("isSuccessful")) throw Failure(task); + using var opened = task.Call("getResult"); + if (opened.Call("isConflict")) throw new CloudSaveException(CloudSaveFailure.Conflict, $"Slot '{slot}' is in conflict; load it and resolve it before saving."); + + var snapshot = opened.Call("getData"); + using var contents = snapshot.Call("getSnapshotContents"); + if (!contents.Call("writeBytes", data)) throw new CloudSaveException(CloudSaveFailure.Io, $"Could not write the data for slot '{slot}'."); + + using var builder = new AndroidJavaObject("com.google.android.gms.games.snapshot.SnapshotMetadataChange$Builder"); + if (!string.IsNullOrEmpty(description)) builder.Call("setDescription", description); + using var change = builder.Call("build"); + AndroidTaskListeners.Listen(client.Call("commitAndClose", snapshot, change), commitTask => + Guard(completion, () => + { + if (!commitTask.Call("isSuccessful")) throw Failure(commitTask); + completion.TrySetResult(true); + })); + })); + }); + return Void(NativeCompletion.ToMainThread(completion)); + } + + /// + public Awaitable DeleteAsync(string slot) + { + var completion = new AwaitableCompletionSource(); + Guard(completion, () => ListMetadata(completion, metadata => + { + var match = Find(metadata, slot); + if (match == null) + { + completion.TrySetResult(true); + return; + } + + AndroidTaskListeners.Listen(Client().Call("delete", match), task => + Guard(completion, () => + { + if (!task.Call("isSuccessful")) throw Failure(task); + completion.TrySetResult(true); + })); + })); + return Void(NativeCompletion.ToMainThread(completion)); + } + + private static AndroidJavaObject Client() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var playGames = new AndroidJavaClass("com.google.android.gms.games.PlayGames"); + return playGames.CallStatic("getSnapshotsClient", activity); + } + + private static int ResolutionPolicy(string name) + { + using var client = new AndroidJavaClass(SnapshotsClientClass); + return client.GetStatic(name); + } + + private static CloudSaveException Failure(AndroidJavaObject task) => + new CloudSaveException(CloudSaveFailure.Io, AndroidTaskListeners.DescribeFailure(task)); + + private static async Awaitable Void(Awaitable operation) + { + await operation; + } + + private static void Guard(AwaitableCompletionSource completion, Action action) + { + try + { + action(); + } + catch (CloudSaveException exception) + { + completion.TrySetException(exception); + } + catch (Exception exception) + { + completion.TrySetException(new CloudSaveException(CloudSaveFailure.Io, exception.Message, exception)); + } + } + + // Loads the metadata of every slot, frozen so it stays valid after the buffer is released. + private static void ListMetadata(AwaitableCompletionSource completion, Action> onLoaded) + { + AndroidTaskListeners.Listen(Client().Call("load", false), task => + Guard(completion, () => + { + if (!task.Call("isSuccessful")) throw Failure(task); + using var annotated = task.Call("getResult"); + using var buffer = annotated.Call("get"); + var metadata = new List(); + try + { + var count = buffer.Call("getCount"); + for (var i = 0; i < count; i++) + { + using var item = buffer.Call("get", i); + metadata.Add(item.Call("freeze")); + } + } + finally + { + buffer.Call("release"); + } + onLoaded(metadata); + })); + } + + private static AndroidJavaObject Find(List metadata, string slot) + { + foreach (var item in metadata) + { + if (item.Call("getUniqueName") == slot) return item; + } + return null; + } + + private static CloudSaveSlot ToSlot(AndroidJavaObject metadata) => + new CloudSaveSlot(metadata.Call("getUniqueName"), metadata.Call("getDescription") ?? string.Empty, + FromMilliseconds(metadata.Call("getLastModifiedTimestamp")), metadata.Call("getDeviceName") ?? string.Empty); + + private static DateTime FromMilliseconds(long milliseconds) => DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime; + + private static CloudSaveVersion ReadVersion(AndroidJavaObject snapshot) + { + using var metadata = snapshot.Call("getMetadata"); + using var contents = snapshot.Call("getSnapshotContents"); + return new CloudSaveVersion(contents.Call("readFully"), metadata.Call("getDescription") ?? string.Empty, + FromMilliseconds(metadata.Call("getLastModifiedTimestamp")), metadata.Call("getDeviceName") ?? string.Empty); + } + + private void OnOpened(AndroidJavaObject client, string slot, AndroidJavaObject task, AwaitableCompletionSource> completion) + { + if (!task.Call("isSuccessful")) throw Failure(task); + using var opened = task.Call("getResult"); + if (!opened.Call("isConflict")) + { + var snapshot = opened.Call("getData"); + var version = ReadVersion(snapshot); + Discard(client, snapshot); + completion.TrySetResult(new[] { version }); + return; + } + + using var conflict = opened.Call("getConflict"); + var current = conflict.Call("getSnapshot"); + using var other = conflict.Call("getConflictingSnapshot"); + var versions = new List { ReadVersion(current), ReadVersion(other) }; + versions.Sort((left, right) => right.ModifiedUtc.CompareTo(left.ModifiedUtc)); + lock (_conflicts) _conflicts[slot] = new PendingConflict(conflict.Call("getConflictId"), current); + completion.TrySetResult(versions); + } + + private static void Discard(AndroidJavaObject client, AndroidJavaObject snapshot) => + client.Call("discardAndClose", snapshot).Dispose(); + + private sealed class PendingConflict + { + public string ConflictId { get; } + public AndroidJavaObject Snapshot { get; } + + public PendingConflict(string conflictId, AndroidJavaObject snapshot) + { + ConflictId = conflictId; + Snapshot = snapshot; + } + } + } +} +#endif diff --git a/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs.meta new file mode 100644 index 0000000..6ebe883 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/AndroidCloudSaveBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 464430a655b964796a5428dc34649dfd \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs b/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs new file mode 100644 index 0000000..5fd0e20 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// + /// Decodes the tagged strings the iOS saved-game bridge returns: K<payload> for success and + /// E<reason>|<message> for failure, where the payload is JSON with an items array. + /// + internal static class CloudSaveProtocol + { + /// Returns a successful reply's payload, or throws the failure the reply carries. + public static string ReadPayload(string reply) + { + if (string.IsNullOrEmpty(reply)) + { + throw new CloudSaveException(CloudSaveFailure.Io, "The platform returned no response."); + } + + switch (reply[0]) + { + case 'K': + return reply.Substring(1); + case 'E': + { + var separator = reply.IndexOf('|'); + var reasonName = separator < 0 ? reply.Substring(1) : reply.Substring(1, separator - 1); + var message = separator < 0 ? "The platform reported an error." : reply.Substring(separator + 1); + var reason = Enum.TryParse(reasonName, out CloudSaveFailure parsed) ? parsed : CloudSaveFailure.Io; + throw new CloudSaveException(reason, message); + } + default: + throw new CloudSaveException(CloudSaveFailure.Io, $"Unrecognized platform response '{reply}'."); + } + } + + /// Decodes the slot list of a successful reply. + public static IReadOnlyList ParseSlots(string json) + { + var slots = new List(); + foreach (var item in Parse(json)) + { + slots.Add(new CloudSaveSlot(item.name, item.description ?? string.Empty, FromUnixSeconds(item.modified), item.device ?? string.Empty)); + } + return slots; + } + + /// Decodes versions and returns them newest first. + public static IReadOnlyList ParseVersions(string json) + { + var versions = new List(); + foreach (var item in Parse(json)) + { + var data = string.IsNullOrEmpty(item.data) ? Array.Empty() : Convert.FromBase64String(item.data); + versions.Add(new CloudSaveVersion(data, item.description ?? string.Empty, FromUnixSeconds(item.modified), item.device ?? string.Empty)); + } + versions.Sort((left, right) => right.ModifiedUtc.CompareTo(left.ModifiedUtc)); + return versions; + } + + private static NativeEntry[] Parse(string json) => + string.IsNullOrEmpty(json) ? Array.Empty() : JsonUtility.FromJson(json)?.items ?? Array.Empty(); + + private static DateTime FromUnixSeconds(double seconds) => + new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(seconds); + + [Serializable] + private sealed class NativeEntries + { + public NativeEntry[] items; + } + + [Serializable] + private sealed class NativeEntry + { + public string name; + public string description; + public double modified; + public string device; + public string data; + } + } +} diff --git a/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs.meta new file mode 100644 index 0000000..fbf96d9 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/CloudSaveProtocol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 45ed0e24f734141adb567194508cf4c7 \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs b/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs new file mode 100644 index 0000000..ffca6b8 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs @@ -0,0 +1,163 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// + /// Process-wide Editor simulation of cloud saves, read by every in the Editor and + /// driven by the Device Simulator panel. Saves live under Library/GameLovers/SimulatedCloudSave. + /// + internal static class EditorCloudSaveState + { + private const string ConflictSuffix = ".conflict"; + + /// Whether the simulated cloud storage is available, to exercise the unavailable path. + public static bool Available = true; + + /// The folder that holds the simulated saves. + public static string Folder => + Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Library", "GameLovers", "SimulatedCloudSave"); + + /// Lists the simulated slots. + public static IReadOnlyList ListSlots() + { + var slots = new List(); + if (!Directory.Exists(Folder)) return slots; + foreach (var file in Directory.GetFiles(Folder, "*.json")) + { + var name = Path.GetFileNameWithoutExtension(file); + if (name.EndsWith(ConflictSuffix, StringComparison.Ordinal)) continue; + var stored = ReadMeta(name); + slots.Add(new CloudSaveSlot(name, stored.description, new DateTime(stored.modifiedTicksUtc, DateTimeKind.Utc), stored.device)); + } + return slots; + } + + /// Reads every stored version of a slot, newest first. + public static IReadOnlyList ReadVersions(string slot) + { + var versions = new List(); + AddVersion(versions, slot); + AddVersion(versions, slot + ConflictSuffix); + versions.Sort((left, right) => right.ModifiedUtc.CompareTo(left.ModifiedUtc)); + return versions; + } + + /// Stores bytes for a slot, or as its conflicting version. + public static void Write(string slot, byte[] data, string description, DateTime modifiedUtc, bool asConflict = false) + { + var key = asConflict ? slot + ConflictSuffix : slot; + Directory.CreateDirectory(Folder); + File.WriteAllBytes(Path.Combine(Folder, key + ".bin"), data); + var stored = new StoredSlot { description = description ?? string.Empty, modifiedTicksUtc = modifiedUtc.Ticks, device = asConflict ? "Other device (simulated)" : "Simulator" }; + File.WriteAllText(Path.Combine(Folder, key + ".json"), JsonUtility.ToJson(stored)); + } + + /// Deletes a slot and its conflicting version. + public static void Delete(string slot) + { + DeleteFiles(slot); + DeleteFiles(slot + ConflictSuffix); + } + + /// Deletes every simulated save. + public static void DeleteAll() + { + if (Directory.Exists(Folder)) Directory.Delete(Folder, true); + } + + /// Adds a second, newer version of an existing slot, as if another device had saved it. + public static bool InjectConflict(string slot, byte[] data) + { + if (!File.Exists(Path.Combine(Folder, slot + ".json"))) return false; + Write(slot, data, "Injected conflict", DateTime.UtcNow, asConflict: true); + return true; + } + + private static void AddVersion(List versions, string key) + { + var binary = Path.Combine(Folder, key + ".bin"); + if (!File.Exists(binary)) return; + var stored = ReadMeta(key); + versions.Add(new CloudSaveVersion(File.ReadAllBytes(binary), stored.description, new DateTime(stored.modifiedTicksUtc, DateTimeKind.Utc), stored.device)); + } + + private static StoredSlot ReadMeta(string key) + { + var file = Path.Combine(Folder, key + ".json"); + return File.Exists(file) ? JsonUtility.FromJson(File.ReadAllText(file)) : new StoredSlot { description = string.Empty, device = "Simulator" }; + } + + private static void DeleteFiles(string key) + { + foreach (var extension in new[] { ".bin", ".json" }) + { + var file = Path.Combine(Folder, key + extension); + if (File.Exists(file)) File.Delete(file); + } + } + + [Serializable] + private sealed class StoredSlot + { + public string description; + public long modifiedTicksUtc; + public string device; + } + } + + /// Editor backend that stores saves on disk instead of calling a platform. + internal sealed class EditorCloudSaveBackend : ICloudSaveBackend + { + /// + public bool IsAvailable => EditorCloudSaveState.Available; + + /// + public Awaitable> ListAsync() => Completed(EditorCloudSaveState.ListSlots()); + + /// + public Awaitable> LoadVersionsAsync(string slot) => Completed(EditorCloudSaveState.ReadVersions(slot)); + + /// + public Awaitable ResolveAsync(string slot, CloudSaveVersion chosen) + { + EditorCloudSaveState.Delete(slot); + EditorCloudSaveState.Write(slot, chosen.Data, chosen.Description, DateTime.UtcNow); + return Completed(); + } + + /// + public Awaitable SaveAsync(string slot, byte[] data, string description) + { + EditorCloudSaveState.Delete(slot); + EditorCloudSaveState.Write(slot, data, description, DateTime.UtcNow); + return Completed(); + } + + /// + public Awaitable DeleteAsync(string slot) + { + EditorCloudSaveState.Delete(slot); + return Completed(); + } + + private static Awaitable Completed(T result) + { + var completion = new AwaitableCompletionSource(); + completion.SetResult(result); + return completion.Awaitable; + } + + private static Awaitable Completed() + { + var completion = new AwaitableCompletionSource(); + completion.SetResult(); + return completion.Awaitable; + } + } +} +#endif diff --git a/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs.meta new file mode 100644 index 0000000..1372490 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/EditorCloudSaveBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 107ebbbaaeb9c4629b050f868a937f3f \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs b/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs new file mode 100644 index 0000000..203e6f5 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// One platform's saved-game storage; failures are reported as . + internal interface ICloudSaveBackend + { + /// True when the platform's storage can be used right now. + bool IsAvailable { get; } + + /// Lists the saved slots. + Awaitable> ListAsync(); + + /// Reads every stored version of a slot, newest first; several mean a conflict. + Awaitable> LoadVersionsAsync(string slot); + + /// Collapses a conflicting slot to the chosen version. + Awaitable ResolveAsync(string slot, CloudSaveVersion chosen); + + /// Writes a slot, replacing its content. + Awaitable SaveAsync(string slot, byte[] data, string description); + + /// Deletes a slot; a missing slot is not an error. + Awaitable DeleteAsync(string slot); + } +} diff --git a/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs.meta new file mode 100644 index 0000000..cebdb73 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/ICloudSaveBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32f6c1b9f94004312a8666b55ff672fa \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs b/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs new file mode 100644 index 0000000..0b3caae --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs @@ -0,0 +1,91 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// Game Center saved-games backend over Plugins/iOS/CloudSave.m. + internal sealed class IosCloudSaveBackend : ICloudSaveBackend + { + private delegate void CloudSaveCallback(int requestId, string result); + + private static readonly Dictionary> _requests = new Dictionary>(); + private static int _nextRequestId = 1; + + /// + public bool IsAvailable => _GameLoversCloudSaveIsAvailable() != 0; + + /// + public async Awaitable> ListAsync() + { + var reply = await Request(id => _GameLoversCloudSaveList(id, OnNativeResult)); + return CloudSaveProtocol.ParseSlots(CloudSaveProtocol.ReadPayload(reply)); + } + + /// + public async Awaitable> LoadVersionsAsync(string slot) + { + var reply = await Request(id => _GameLoversCloudSaveLoadVersions(slot, id, OnNativeResult)); + return CloudSaveProtocol.ParseVersions(CloudSaveProtocol.ReadPayload(reply)); + } + + /// + public async Awaitable ResolveAsync(string slot, CloudSaveVersion chosen) + { + var encoded = Convert.ToBase64String(chosen.Data); + CloudSaveProtocol.ReadPayload(await Request(id => _GameLoversCloudSaveResolve(slot, encoded, id, OnNativeResult))); + } + + /// + public async Awaitable SaveAsync(string slot, byte[] data, string description) + { + // Game Center saved games carry no description, so it is not sent. + var encoded = Convert.ToBase64String(data); + CloudSaveProtocol.ReadPayload(await Request(id => _GameLoversCloudSaveSave(slot, encoded, id, OnNativeResult))); + } + + /// + public async Awaitable DeleteAsync(string slot) + { + CloudSaveProtocol.ReadPayload(await Request(id => _GameLoversCloudSaveDelete(slot, id, OnNativeResult))); + } + + [DllImport("__Internal")] private static extern int _GameLoversCloudSaveIsAvailable(); + [DllImport("__Internal")] private static extern void _GameLoversCloudSaveList(int requestId, CloudSaveCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversCloudSaveLoadVersions(string slot, int requestId, CloudSaveCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversCloudSaveResolve(string slot, string base64, int requestId, CloudSaveCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversCloudSaveSave(string slot, string base64, int requestId, CloudSaveCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversCloudSaveDelete(string slot, int requestId, CloudSaveCallback callback); + + private static Awaitable Request(Action start) + { + var completion = new AwaitableCompletionSource(); + int id; + lock (_requests) + { + id = _nextRequestId++; + _requests[id] = completion; + } + + start(id); + return NativeCompletion.ToMainThread(completion); + } + + [AOT.MonoPInvokeCallback(typeof(CloudSaveCallback))] + private static void OnNativeResult(int requestId, string result) + { + AwaitableCompletionSource completion; + lock (_requests) + { + if (!_requests.TryGetValue(requestId, out completion)) return; + _requests.Remove(requestId); + } + + completion.TrySetResult(result); + } + } +} +#endif diff --git a/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs.meta new file mode 100644 index 0000000..e98c5fc --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/IosCloudSaveBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe65fa9c854e2421e8169092deb88b8e \ No newline at end of file diff --git a/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs b/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs new file mode 100644 index 0000000..8a5b3d4 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// Backend for platforms without saved-game storage: the cloud is reported as unavailable. + internal sealed class NoOpCloudSaveBackend : ICloudSaveBackend + { + /// + public bool IsAvailable => false; + + /// + public Awaitable> ListAsync() => throw Unavailable(); + + /// + public Awaitable> LoadVersionsAsync(string slot) => throw Unavailable(); + + /// + public Awaitable ResolveAsync(string slot, CloudSaveVersion chosen) => throw Unavailable(); + + /// + public Awaitable SaveAsync(string slot, byte[] data, string description) => throw Unavailable(); + + /// + public Awaitable DeleteAsync(string slot) => throw Unavailable(); + + private static CloudSaveException Unavailable() => + new CloudSaveException(CloudSaveFailure.CloudUnavailable, "Cloud saves are only available on iOS and Android."); + } +} diff --git a/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs.meta b/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs.meta new file mode 100644 index 0000000..33fdbe6 --- /dev/null +++ b/Runtime/GamePlatform/CloudSave/Internal/NoOpCloudSaveBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ecd0359876055449bb4ed586910ecef5 \ No newline at end of file diff --git a/Runtime/GamePlatform/GamePlatformServices.cs b/Runtime/GamePlatform/GamePlatformServices.cs new file mode 100644 index 0000000..780ca8d --- /dev/null +++ b/Runtime/GamePlatform/GamePlatformServices.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using GameLovers.MobileServices.GamePlatform.Internal; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform +{ + /// + public sealed class GamePlatformServices : IGamePlatformServices + { + private readonly IGamePlatformBackend _backend; + private List> _authenticationWaiters; + + /// + public bool IsAuthenticated => _backend.IsAuthenticated; + + /// + public GamePlayer LocalPlayer => IsAuthenticated ? _backend.LocalPlayer : null; + + /// + public ICloudSaveService CloudSave { get; } + + public GamePlatformServices() : this(CreateBackend(), null, CreateCloudSaveBackend()) + { + } + + internal GamePlatformServices(IGamePlatformBackend backend, ICloudSaveService cloudSave = null, ICloudSaveBackend cloudSaveBackend = null) + { + _backend = backend; + CloudSave = cloudSave ?? new CloudSaveService(() => IsAuthenticated, cloudSaveBackend ?? new NoOpCloudSaveBackend()); + } + + /// + public async Awaitable AuthenticateAsync() + { + if (IsAuthenticated) + { + return true; + } + + // Awaitables can only be awaited once, so callers that arrive mid sign-in wait on their own completion. + if (_authenticationWaiters != null) + { + var waiter = new AwaitableCompletionSource(); + _authenticationWaiters.Add(waiter); + return await waiter.Awaitable; + } + + _authenticationWaiters = new List>(); + var success = false; + try + { + success = await _backend.AuthenticateAsync(); + } + finally + { + var waiters = _authenticationWaiters; + _authenticationWaiters = null; + foreach (var waiter in waiters) waiter.SetResult(success); + } + return success; + } + + /// + public Awaitable ReportScoreAsync(string leaderboardId, long score) + { + if (!CanReport(leaderboardId, "a leaderboard id", nameof(ReportScoreAsync))) + { + return Failed(); + } + return _backend.ReportScoreAsync(leaderboardId, score); + } + + /// + public Awaitable UnlockAchievementAsync(string achievementId) + { + if (!CanReport(achievementId, "an achievement id", nameof(UnlockAchievementAsync))) + { + return Failed(); + } + return _backend.UnlockAchievementAsync(achievementId); + } + + /// + public Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps = 0) + { + if (!CanReport(achievementId, "an achievement id", nameof(IncrementAchievementAsync))) + { + return Failed(); + } + + if (steps <= 0) + { + Debug.LogWarning($"[GameLovers.MobileServices] {nameof(IncrementAchievementAsync)} needs a positive step count, got {steps}."); + return Failed(); + } + return _backend.IncrementAchievementAsync(achievementId, steps, totalSteps); + } + + /// + public void ShowLeaderboards(string leaderboardId = null) + { + if (!IsAuthenticated) + { + Debug.LogWarning($"[GameLovers.MobileServices] {nameof(ShowLeaderboards)} needs a signed-in player; call {nameof(AuthenticateAsync)} first."); + return; + } + _backend.ShowLeaderboards(leaderboardId); + } + + /// + public void ShowAchievements() + { + if (!IsAuthenticated) + { + Debug.LogWarning($"[GameLovers.MobileServices] {nameof(ShowAchievements)} needs a signed-in player; call {nameof(AuthenticateAsync)} first."); + return; + } + _backend.ShowAchievements(); + } + + private static IGamePlatformBackend CreateBackend() + { +#if UNITY_EDITOR + return new EditorGamePlatformBackend(); +#elif UNITY_IOS + return new IosGamePlatformBackend(); +#elif UNITY_ANDROID + return new AndroidGamePlatformBackend(); +#else + return new NoOpGamePlatformBackend(); +#endif + } + + private static ICloudSaveBackend CreateCloudSaveBackend() + { +#if UNITY_EDITOR + return new EditorCloudSaveBackend(); +#elif UNITY_IOS + return new IosCloudSaveBackend(); +#elif UNITY_ANDROID + return new AndroidCloudSaveBackend(); +#else + return new NoOpCloudSaveBackend(); +#endif + } + + private static Awaitable Failed() + { + var completion = new AwaitableCompletionSource(); + completion.SetResult(false); + return completion.Awaitable; + } + + private bool CanReport(string id, string idDescription, string operation) + { + if (string.IsNullOrEmpty(id)) + { + Debug.LogWarning($"[GameLovers.MobileServices] {operation} needs {idDescription}."); + return false; + } + + if (!IsAuthenticated) + { + Debug.LogWarning($"[GameLovers.MobileServices] {operation} needs a signed-in player; call {nameof(AuthenticateAsync)} first."); + return false; + } + return true; + } + } +} diff --git a/Runtime/GamePlatform/GamePlatformServices.cs.meta b/Runtime/GamePlatform/GamePlatformServices.cs.meta new file mode 100644 index 0000000..29c321e --- /dev/null +++ b/Runtime/GamePlatform/GamePlatformServices.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1733c0262fd854daf88c4df07d118a77 \ No newline at end of file diff --git a/Runtime/GamePlatform/IGamePlatformServices.cs b/Runtime/GamePlatform/IGamePlatformServices.cs new file mode 100644 index 0000000..b80e6fb --- /dev/null +++ b/Runtime/GamePlatform/IGamePlatformServices.cs @@ -0,0 +1,125 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform +{ + /// + /// The signed-in player of the platform's game service: Game Center or Google Play Games. + /// + public sealed class GamePlayer + { + /// The platform's stable identifier for the player in this game. + public string Id { get; } + + /// The name the player chose on the platform. + public string DisplayName { get; } + + public GamePlayer(string id, string displayName) + { + Id = id; + DisplayName = displayName; + } + } + + /// + /// A leaderboard or achievement id that differs per platform. Game Center and Google Play Games each issue their + /// own ids, so keep both in one value and pass it anywhere an id string is expected. + /// + public readonly struct PlatformId + { + /// The Game Center id. + public string Ios { get; } + + /// The Google Play Games id. + public string Android { get; } + + public PlatformId(string ios, string android) + { + Ios = ios; + Android = android; + } + + /// + /// The id for the running platform; outside iOS and Android it falls back to whichever id is set. + /// + public static implicit operator string(PlatformId id) => id.Resolve(Application.platform); + + /// Picks the id that belongs to . + internal string Resolve(RuntimePlatform platform) + { + switch (platform) + { + case RuntimePlatform.IPhonePlayer: return Ios; + case RuntimePlatform.Android: return Android; + default: return string.IsNullOrEmpty(Ios) ? Android : Ios; + } + } + } + + /// + /// Sign-in, leaderboards and achievements on the platform's game service: Game Center on iOS and Google Play + /// Games on Android. It is opt-in: turn on the Game Center capability and enter the Play Games project id in the + /// Mobile Services Config. + /// + /// + /// Every call that reports progress needs a signed-in player and returns false, after a log message, when + /// there is none. Call first. + /// + public interface IGamePlatformServices + { + /// True while a player is signed in to the platform's game service. + bool IsAuthenticated { get; } + + /// The signed-in player, or null when nobody is signed in. + GamePlayer LocalPlayer { get; } + + /// + /// Slot-based cloud saves on Game Center saved games (iOS) or Google Play Games saved games (Android). + /// + ICloudSaveService CloudSave { get; } + + /// + /// Signs the player in, showing the platform's sign-in UI when needed. Returns immediately when already + /// signed in. + /// + /// True when a player is signed in afterwards. + Awaitable AuthenticateAsync(); + + /// + /// Submits a score to a leaderboard. The platform keeps the best score according to the board's rules. + /// + /// The leaderboard id; pass a to supply one per platform. + /// The score value. + /// True when the platform accepted the score. + Awaitable ReportScoreAsync(string leaderboardId, long score); + + /// + /// Unlocks an achievement. + /// + /// The achievement id; pass a to supply one per platform. + /// True when the platform accepted the unlock. + Awaitable UnlockAchievementAsync(string achievementId); + + /// + /// Adds progress to an incremental achievement. + /// + /// The achievement id; pass a to supply one per platform. + /// The number of steps to add; must be positive. + /// + /// The steps needed to complete the achievement. Required on iOS, where Game Center tracks a percentage; + /// Android reads the total from the Play Console and ignores it. + /// + /// True when the platform accepted the progress. + Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps = 0); + + /// + /// Opens the platform's leaderboard UI, for one board or for all boards when no id is given. + /// + void ShowLeaderboards(string leaderboardId = null); + + /// + /// Opens the platform's achievements UI. + /// + void ShowAchievements(); + } +} diff --git a/Runtime/GamePlatform/IGamePlatformServices.cs.meta b/Runtime/GamePlatform/IGamePlatformServices.cs.meta new file mode 100644 index 0000000..350ebc1 --- /dev/null +++ b/Runtime/GamePlatform/IGamePlatformServices.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b08b7faf15df4e0c8e56178ea679064 \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal.meta b/Runtime/GamePlatform/Internal.meta new file mode 100644 index 0000000..911b9fe --- /dev/null +++ b/Runtime/GamePlatform/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cc2ef12b1cae44e1eb67155adce7500f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs b/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs new file mode 100644 index 0000000..3ff030b --- /dev/null +++ b/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs @@ -0,0 +1,171 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// + /// Google Play Games Services v2 backend. It talks to the SDK through JNI and OnCompleteListener proxies, so + /// the package compiles without the SDK; the build postprocessor adds it when a Play Games project id is configured. + /// + internal sealed class AndroidGamePlatformBackend : IGamePlatformBackend + { + private const string PlayGamesClass = "com.google.android.gms.games.PlayGames"; + private const int ShowUiRequestCode = 9004; + + private bool _authenticated; + private GamePlayer _player; + + /// + public bool IsAuthenticated => _authenticated; + + /// + public GamePlayer LocalPlayer => _player; + + public AndroidGamePlatformBackend() + { + try + { + using var sdk = new AndroidJavaClass("com.google.android.gms.games.PlayGamesSdk"); + using var activity = CurrentActivity(); + sdk.CallStatic("initialize", activity); + } + catch (Exception exception) + { + Debug.LogWarning($"[GameLovers.MobileServices] Play Games Services is unavailable ({exception.Message}). " + + "Enter the Play Games project id in the Mobile Services Config so the build includes the SDK."); + } + } + + /// + public Awaitable AuthenticateAsync() + { + var completion = new AwaitableCompletionSource(); + try + { + var client = GetClient("getGamesSignInClient"); + AndroidTaskListeners.Listen(client.Call("isAuthenticated"), checkTask => + { + if (IsAuthenticatedResult(checkTask)) + { + LoadPlayer(completion); + return; + } + + AndroidTaskListeners.Listen(client.Call("signIn"), signInTask => + { + if (!IsAuthenticatedResult(signInTask)) + { + completion.TrySetResult(false); + return; + } + + LoadPlayer(completion); + }); + }); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] Play Games sign-in failed: {exception.Message}"); + completion.TrySetResult(false); + } + return NativeCompletion.ToMainThread(completion); + } + + /// + public Awaitable ReportScoreAsync(string leaderboardId, long score) => + Succeeded(() => GetClient("getLeaderboardsClient").Call("submitScoreImmediate", leaderboardId, score)); + + /// + public Awaitable UnlockAchievementAsync(string achievementId) => + Succeeded(() => GetClient("getAchievementsClient").Call("unlockImmediate", achievementId)); + + /// + public Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps) => + Succeeded(() => GetClient("getAchievementsClient").Call("incrementImmediate", achievementId, steps)); + + /// + public void ShowLeaderboards(string leaderboardId) + { + var hasId = !string.IsNullOrEmpty(leaderboardId); + ShowUi("getLeaderboardsClient", hasId ? "getLeaderboardIntent" : "getAllLeaderboardsIntent", hasId ? new object[] { leaderboardId } : Array.Empty()); + } + + /// + public void ShowAchievements() => ShowUi("getAchievementsClient", "getAchievementsIntent", Array.Empty()); + + private static AndroidJavaObject CurrentActivity() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + return unityPlayer.GetStatic("currentActivity"); + } + + private static AndroidJavaObject GetClient(string factoryMethod) + { + using var playGames = new AndroidJavaClass(PlayGamesClass); + using var activity = CurrentActivity(); + return playGames.CallStatic(factoryMethod, activity); + } + + private static bool IsAuthenticatedResult(AndroidJavaObject task) + { + if (!task.Call("isSuccessful")) return false; + using var result = task.Call("getResult"); + return result != null && result.Call("isAuthenticated"); + } + + private void LoadPlayer(AwaitableCompletionSource completion) + { + AndroidTaskListeners.Listen(GetClient("getPlayersClient").Call("getCurrentPlayer"), task => + { + if (!task.Call("isSuccessful")) + { + _authenticated = false; + _player = null; + completion.TrySetResult(false); + return; + } + + using var player = task.Call("getResult"); + _player = new GamePlayer(player.Call("getPlayerId"), player.Call("getDisplayName")); + _authenticated = true; + completion.TrySetResult(true); + }); + } + + private Awaitable Succeeded(Func startTask) + { + var completion = new AwaitableCompletionSource(); + try + { + AndroidTaskListeners.Listen(startTask(), task => completion.TrySetResult(task.Call("isSuccessful"))); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] Play Games request failed: {exception.Message}"); + completion.TrySetResult(false); + } + return NativeCompletion.ToMainThread(completion); + } + + private void ShowUi(string clientFactory, string intentMethod, object[] intentArguments) + { + try + { + AndroidTaskListeners.Listen(GetClient(clientFactory).Call(intentMethod, intentArguments), task => + { + if (!task.Call("isSuccessful")) return; + using var intent = task.Call("getResult"); + using var activity = CurrentActivity(); + activity.Call("startActivityForResult", intent, ShowUiRequestCode); + }); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] Play Games UI failed: {exception.Message}"); + } + } + } +} +#endif diff --git a/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs.meta b/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs.meta new file mode 100644 index 0000000..a1e720f --- /dev/null +++ b/Runtime/GamePlatform/Internal/AndroidGamePlatformBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 509f70dbf84294ce78aba8ec41443e1f \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs b/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs new file mode 100644 index 0000000..a741c46 --- /dev/null +++ b/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs @@ -0,0 +1,58 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// Attaches managed callbacks to Google Play Services Task objects through JNI. + internal static class AndroidTaskListeners + { + private static readonly List _listeners = new List(); + + /// + /// Runs when finishes. Callbacks arrive on Android's UI + /// thread; the proxy is kept alive until it has run once. + /// + public static void Listen(AndroidJavaObject task, Action onComplete) + { + CompleteListener listener = null; + listener = new CompleteListener(finished => + { + lock (_listeners) _listeners.Remove(listener); + try + { + onComplete(finished); + } + catch (Exception exception) + { + Debug.LogError($"[GameLovers.MobileServices] Play Games callback failed: {exception.Message}"); + } + }); + lock (_listeners) _listeners.Add(listener); + task.Call("addOnCompleteListener", listener); + } + + /// The failure message of a finished . + public static string DescribeFailure(AndroidJavaObject task) + { + using var exception = task.Call("getException"); + return exception == null ? "The task failed." : exception.Call("toString"); + } + + private sealed class CompleteListener : AndroidJavaProxy + { + private readonly Action _onComplete; + + public CompleteListener(Action onComplete) : base("com.google.android.gms.tasks.OnCompleteListener") + { + _onComplete = onComplete; + } + + // ReSharper disable once InconsistentNaming + public void onComplete(AndroidJavaObject task) => _onComplete(task); + } + } +} +#endif diff --git a/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs.meta b/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs.meta new file mode 100644 index 0000000..30e73b4 --- /dev/null +++ b/Runtime/GamePlatform/Internal/AndroidTaskListeners.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f68535b88fd14d6894ce165146e2b5a \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs b/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs new file mode 100644 index 0000000..3907ece --- /dev/null +++ b/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs @@ -0,0 +1,118 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// + /// Process-wide Editor simulation of the game service, driven by the Device Simulator panel and read by every + /// in the Editor. + /// + internal static class EditorGamePlatformState + { + private const int MaxActivity = 20; + + private static readonly List _activity = new List(); + + /// Whether a sign-in request succeeds, so the failure path can be exercised. + public static bool SignInSucceeds = true; + + /// The name the simulated player has. + public static string PlayerName = "Simulator Player"; + + /// Raised when the signed-in state or the activity log changes. + public static event Action Changed; + + /// True while the simulated player is signed in. + public static bool SignedIn { get; private set; } + + /// The most recent simulated game-service calls, newest first. + public static IReadOnlyList Activity => _activity; + + /// Sets the simulated signed-in state and records the change. + public static void SetSignedIn(bool signedIn) + { + SignedIn = signedIn; + Record(signedIn ? "Signed in" : "Signed out"); + } + + /// Adds an entry to the activity log, newest first. + public static void Record(string entry) + { + _activity.Insert(0, entry); + if (_activity.Count > MaxActivity) _activity.RemoveAt(_activity.Count - 1); + Changed?.Invoke(); + } + + /// Signs the simulated player out and restores the defaults. + public static void Reset() + { + SignedIn = false; + SignInSucceeds = true; + PlayerName = "Simulator Player"; + _activity.Clear(); + Changed?.Invoke(); + } + } + + /// Editor backend that records what the game reports instead of calling a platform. + internal sealed class EditorGamePlatformBackend : IGamePlatformBackend + { + /// + public bool IsAuthenticated => EditorGamePlatformState.SignedIn; + + /// + public GamePlayer LocalPlayer => IsAuthenticated ? new GamePlayer("simulator-player", EditorGamePlatformState.PlayerName) : null; + + /// + public Awaitable AuthenticateAsync() + { + if (!EditorGamePlatformState.SignInSucceeds) + { + EditorGamePlatformState.Record("Sign-in failed (simulated)"); + return Completed(IsAuthenticated); + } + + EditorGamePlatformState.SetSignedIn(true); + return Completed(true); + } + + /// + public Awaitable ReportScoreAsync(string leaderboardId, long score) + { + EditorGamePlatformState.Record($"Score {score} reported to '{leaderboardId}'"); + return Completed(true); + } + + /// + public Awaitable UnlockAchievementAsync(string achievementId) + { + EditorGamePlatformState.Record($"Achievement '{achievementId}' unlocked"); + return Completed(true); + } + + /// + public Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps) + { + EditorGamePlatformState.Record($"Achievement '{achievementId}' +{steps} steps (total {totalSteps})"); + return Completed(true); + } + + /// + public void ShowLeaderboards(string leaderboardId) => + EditorGamePlatformState.Record(string.IsNullOrEmpty(leaderboardId) ? "Showed all leaderboards" : $"Showed leaderboard '{leaderboardId}'"); + + /// + public void ShowAchievements() => EditorGamePlatformState.Record("Showed achievements"); + + private static Awaitable Completed(bool result) + { + var completion = new AwaitableCompletionSource(); + completion.SetResult(result); + return completion.Awaitable; + } + } +} +#endif diff --git a/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs.meta b/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs.meta new file mode 100644 index 0000000..72d8548 --- /dev/null +++ b/Runtime/GamePlatform/Internal/EditorGamePlatformBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c85fe1ab409d43b980d798ca30e19d9 \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs b/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs new file mode 100644 index 0000000..b7f1432 --- /dev/null +++ b/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs @@ -0,0 +1,33 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// One platform's game service: Game Center, Google Play Games, the Editor simulation or none. + internal interface IGamePlatformBackend + { + /// True while the platform reports a signed-in player. + bool IsAuthenticated { get; } + + /// The signed-in player, or null. + GamePlayer LocalPlayer { get; } + + /// Signs the player in, showing the platform's UI if needed; true when signed in afterwards. + Awaitable AuthenticateAsync(); + + /// Submits a score to a leaderboard; true when the platform accepted it. + Awaitable ReportScoreAsync(string leaderboardId, long score); + + /// Unlocks an achievement; true when the platform accepted it. + Awaitable UnlockAchievementAsync(string achievementId); + + /// Adds steps to an incremental achievement; true when the platform accepted it. + Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps); + + /// Opens the leaderboard UI for one board, or for all boards when the id is null or empty. + void ShowLeaderboards(string leaderboardId); + + /// Opens the achievements UI. + void ShowAchievements(); + } +} diff --git a/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs.meta b/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs.meta new file mode 100644 index 0000000..a83bfbe --- /dev/null +++ b/Runtime/GamePlatform/Internal/IGamePlatformBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d5dbcc66024174a42b3d46470b4d9588 \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs b/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs new file mode 100644 index 0000000..540efd8 --- /dev/null +++ b/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs @@ -0,0 +1,106 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System.Collections.Generic; +using System.Runtime.InteropServices; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// Game Center backend over Plugins/iOS/GamePlatform.m. + internal sealed class IosGamePlatformBackend : IGamePlatformBackend + { + private delegate void GamePlatformCallback(int requestId, string result); + + private static readonly Dictionary> _requests = new Dictionary>(); + private static int _nextRequestId = 1; + + private GamePlayer _player; + + /// + public bool IsAuthenticated => _GameLoversGamePlatformIsAuthenticated() != 0; + + /// + public GamePlayer LocalPlayer => _player; + + /// + public async Awaitable AuthenticateAsync() + { + var completion = new AwaitableCompletionSource(); + _GameLoversGamePlatformAuthenticate(Register(completion), OnNativeResult); + var payload = await NativeCompletion.ToMainThread(completion); + _player = ParsePlayer(payload); + return _player != null; + } + + /// + public async Awaitable ReportScoreAsync(string leaderboardId, long score) + { + var completion = new AwaitableCompletionSource(); + _GameLoversGamePlatformReportScore(leaderboardId, score, Register(completion), OnNativeResult); + return await NativeCompletion.ToMainThread(completion) != null; + } + + /// + public async Awaitable UnlockAchievementAsync(string achievementId) + { + var completion = new AwaitableCompletionSource(); + _GameLoversGamePlatformUnlockAchievement(achievementId, Register(completion), OnNativeResult); + return await NativeCompletion.ToMainThread(completion) != null; + } + + /// + public async Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps) + { + var completion = new AwaitableCompletionSource(); + _GameLoversGamePlatformIncrementAchievement(achievementId, steps, totalSteps, Register(completion), OnNativeResult); + return await NativeCompletion.ToMainThread(completion) != null; + } + + /// + public void ShowLeaderboards(string leaderboardId) => _GameLoversGamePlatformShowLeaderboards(leaderboardId); + + /// + public void ShowAchievements() => _GameLoversGamePlatformShowAchievements(); + + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformAuthenticate(int requestId, GamePlatformCallback callback); + [DllImport("__Internal")] private static extern int _GameLoversGamePlatformIsAuthenticated(); + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformReportScore(string leaderboardId, long score, int requestId, GamePlatformCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformUnlockAchievement(string achievementId, int requestId, GamePlatformCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformIncrementAchievement(string achievementId, int steps, int totalSteps, int requestId, GamePlatformCallback callback); + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformShowLeaderboards(string leaderboardId); + [DllImport("__Internal")] private static extern void _GameLoversGamePlatformShowAchievements(); + + private static GamePlayer ParsePlayer(string payload) + { + if (string.IsNullOrEmpty(payload)) return null; + var separator = payload.IndexOf('\n'); + return separator < 0 + ? new GamePlayer(payload, string.Empty) + : new GamePlayer(payload.Substring(0, separator), payload.Substring(separator + 1)); + } + + private static int Register(AwaitableCompletionSource completion) + { + lock (_requests) + { + var id = _nextRequestId++; + _requests[id] = completion; + return id; + } + } + + [AOT.MonoPInvokeCallback(typeof(GamePlatformCallback))] + private static void OnNativeResult(int requestId, string result) + { + AwaitableCompletionSource completion; + lock (_requests) + { + if (!_requests.TryGetValue(requestId, out completion)) return; + _requests.Remove(requestId); + } + + completion.TrySetResult(result); + } + } +} +#endif diff --git a/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs.meta b/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs.meta new file mode 100644 index 0000000..9327a26 --- /dev/null +++ b/Runtime/GamePlatform/Internal/IosGamePlatformBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b237b252b694445cbb840b404aa70387 \ No newline at end of file diff --git a/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs b/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs new file mode 100644 index 0000000..0c7dafb --- /dev/null +++ b/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs @@ -0,0 +1,45 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.GamePlatform.Internal +{ + /// Backend for platforms without a game service: every request fails. + internal sealed class NoOpGamePlatformBackend : IGamePlatformBackend + { + /// + public bool IsAuthenticated => false; + + /// + public GamePlayer LocalPlayer => null; + + /// + public Awaitable AuthenticateAsync() => Failed(); + + /// + public Awaitable ReportScoreAsync(string leaderboardId, long score) => Failed(); + + /// + public Awaitable UnlockAchievementAsync(string achievementId) => Failed(); + + /// + public Awaitable IncrementAchievementAsync(string achievementId, int steps, int totalSteps) => Failed(); + + /// + public void ShowLeaderboards(string leaderboardId) + { + } + + /// + public void ShowAchievements() + { + } + + private static Awaitable Failed() + { + Debug.LogWarning("[GameLovers.MobileServices] Game services are only available on iOS and Android."); + var completion = new AwaitableCompletionSource(); + completion.SetResult(false); + return completion.Awaitable; + } + } +} diff --git a/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs.meta b/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs.meta new file mode 100644 index 0000000..57702f2 --- /dev/null +++ b/Runtime/GamePlatform/Internal/NoOpGamePlatformBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2612d81d826cb44c88474c9e648cea21 \ No newline at end of file diff --git a/Runtime/Gestures/ActiveGesture.cs b/Runtime/Gestures/ActiveGesture.cs index f35bf56..3e8830c 100755 --- a/Runtime/Gestures/ActiveGesture.cs +++ b/Runtime/Gestures/ActiveGesture.cs @@ -59,7 +59,7 @@ internal sealed class ActiveGesture public float TravelDistance; // Accumulated sum of all normalized movement vectors. - private Vector2 accumulatedNormalized; + private Vector2 _accumulatedNormalized; public ActiveGesture(int inputId, Vector2 startPosition, double startTime) { @@ -68,7 +68,7 @@ public ActiveGesture(int inputId, Vector2 startPosition, double startTime) EndPosition = StartPosition = startPosition; Samples = 1; SwipeDirectionSameness = 1; - accumulatedNormalized = Vector2.zero; + _accumulatedNormalized = Vector2.zero; } /// @@ -98,9 +98,9 @@ public void SubmitPoint(Vector2 position, double time) PreviousPosition = EndPosition; EndPosition = position; - accumulatedNormalized += toNewPosition; + _accumulatedNormalized += toNewPosition; - SwipeDirectionSameness = Vector2.Dot(toNewEndPosition, accumulatedNormalized / (Samples - 1)); + SwipeDirectionSameness = Vector2.Dot(toNewEndPosition, _accumulatedNormalized / (Samples - 1)); TravelDistance += distanceMoved; } diff --git a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs index 72810b4..d31300f 100644 --- a/Runtime/Haptics/Internal/AndroidHapticsBackend.cs +++ b/Runtime/Haptics/Internal/AndroidHapticsBackend.cs @@ -1,4 +1,5 @@ #if UNITY_ANDROID && !UNITY_EDITOR +using GameLovers.MobileServices.Device.Internal; using UnityEngine; #endif @@ -25,7 +26,7 @@ public bool IsSupported get { #if UNITY_ANDROID && !UNITY_EDITOR - if (GetApiLevel() < MinimumVibrationEffectApi) + if (AndroidApiLevel.Current < MinimumVibrationEffectApi) { WarnUnsupportedApiOnce(); return false; @@ -99,22 +100,6 @@ public void Stop() } #if UNITY_ANDROID && !UNITY_EDITOR - private static int GetApiLevel() - { - try - { - using (var version = new AndroidJavaClass("android.os.Build$VERSION")) - { - return version.GetStatic("SDK_INT"); - } - } - catch - { - // Treat an unavailable API query as unsupported rather than attempting to load VibrationEffect. - return 0; - } - } - private static void WarnUnsupportedApiOnce() { if (_unsupportedApiWarningShown) diff --git a/Runtime/Haptics/Internal/EditorHapticsBackend.cs b/Runtime/Haptics/Internal/EditorHapticsBackend.cs index 7930dc9..dcddb01 100644 --- a/Runtime/Haptics/Internal/EditorHapticsBackend.cs +++ b/Runtime/Haptics/Internal/EditorHapticsBackend.cs @@ -4,36 +4,43 @@ 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. + /// Editor backend. Optionally logs calls 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 { + /// Controls whether Editor haptics calls write diagnostic messages to the Unity console. + internal static bool DebugLoggingEnabled { get; set; } + /// public bool IsSupported => false; /// public void PlayPresetOneShot(HapticPreset preset) { + if (!DebugLoggingEnabled) return; Debug.Log($"[Haptics] PlayPresetOneShot({preset})"); } /// public void PlayPresetLoop(HapticPreset preset) { + if (!DebugLoggingEnabled) return; Debug.Log($"[Haptics] PlayPresetLoop({preset})"); } /// public void PlayCustom(float intensity01, float durationMs) { + if (!DebugLoggingEnabled) return; Debug.Log($"[Haptics] PlayCustom(intensity={intensity01:0.00}, durationMs={durationMs:0})"); } /// public void Stop() { + if (!DebugLoggingEnabled) return; Debug.Log("[Haptics] Stop"); } } diff --git a/Runtime/IMobileService.cs b/Runtime/IMobileService.cs index cd3eada..9e68185 100644 --- a/Runtime/IMobileService.cs +++ b/Runtime/IMobileService.cs @@ -28,7 +28,7 @@ public interface IMobileService IHapticsService Haptics { get; } /// - /// Device sub-services (safe area, battery, audio session, permissions, ATT, deep link). + /// Device sub-services (battery, audio session, permissions, ATT, deep link). /// IDeviceService Device { get; } } diff --git a/Runtime/NativeCompletion.cs b/Runtime/NativeCompletion.cs new file mode 100644 index 0000000..6f6ba98 --- /dev/null +++ b/Runtime/NativeCompletion.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices +{ + /// Hands the result of a native callback back to Unity's main thread. + internal static class NativeCompletion + { + /// + /// Awaits , which a native callback may complete on any thread, and resumes on the + /// main thread so consumer code never runs on an OS callback thread. + /// + public static async Awaitable ToMainThread(AwaitableCompletionSource completion) + { + var result = await completion.Awaitable; + await Awaitable.MainThreadAsync(); + return result; + } + } +} diff --git a/Runtime/NativeCompletion.cs.meta b/Runtime/NativeCompletion.cs.meta new file mode 100644 index 0000000..8b57478 --- /dev/null +++ b/Runtime/NativeCompletion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2198b10715a2245ddbe1a028b7b9547a \ No newline at end of file diff --git a/Runtime/NativeUi/INativeUiService.cs b/Runtime/NativeUi/INativeUiService.cs index de21cc5..c7820bb 100644 --- a/Runtime/NativeUi/INativeUiService.cs +++ b/Runtime/NativeUi/INativeUiService.cs @@ -41,6 +41,18 @@ Awaitable ShowAlertPopUpAsync( /// void Share(string text, string url = null, string imagePath = null, string title = null); + + /// + Awaitable ShareScreenshotAsync(string text = null, string url = null, string title = null); + + /// + void OpenInAppBrowser(string url); + + /// + Awaitable SaveToGalleryAsync(string filePath, MediaType mediaType = MediaType.Image); + + /// + Awaitable PickImageAsync(); } /// @@ -84,5 +96,19 @@ public void ShowToastMessage(string message, bool isLongDuration) /// public void Share(string text, string url = null, string imagePath = null, string title = null) => NativeUiService.Share(text, url, imagePath, title); + + /// + public Awaitable ShareScreenshotAsync(string text = null, string url = null, string title = null) + => NativeUiService.ShareScreenshotAsync(text, url, title); + + /// + public void OpenInAppBrowser(string url) => NativeUiService.OpenInAppBrowser(url); + + /// + public Awaitable SaveToGalleryAsync(string filePath, MediaType mediaType = MediaType.Image) + => NativeMediaService.SaveToGalleryAsync(filePath, mediaType); + + /// + public Awaitable PickImageAsync() => NativeMediaService.PickImageAsync(); } } diff --git a/Runtime/NativeUi/NativeMediaService.cs b/Runtime/NativeUi/NativeMediaService.cs new file mode 100644 index 0000000..45159f5 --- /dev/null +++ b/Runtime/NativeUi/NativeMediaService.cs @@ -0,0 +1,227 @@ +using System; +using System.IO; +using UnityEngine; +#if UNITY_IOS && !UNITY_EDITOR +using System.Collections.Generic; +using System.Runtime.InteropServices; +#elif UNITY_ANDROID && !UNITY_EDITOR +using System.Collections.Generic; +#endif + +// ReSharper disable once CheckNamespace +namespace GameLovers.MobileServices.NativeUi +{ + /// + /// The kind of media saves. + /// + public enum MediaType + { + Image, // A still image such as a PNG or JPEG. + Video // A video file such as an MP4. + } + + /// + /// Saves files to the device gallery and picks an image with the operating system's own picker. Neither call + /// asks the player for a permission, except that iOS shows its add-only photo prompt the first time something is saved. + /// + public static class NativeMediaService + { +#if UNITY_EDITOR + /// Editor simulator hook that stands in for the gallery; true when the file was saved. + internal static Func EditorSaveToGalleryOverride; + + /// Editor simulator hook that stands in for the picker; returns the chosen path or null. + internal static Func EditorPickImageOverride; +#endif + +#if UNITY_IOS && !UNITY_EDITOR + private delegate void MediaCallback(int requestId, string result); + + private static readonly Dictionary> _requests = new Dictionary>(); + private static int _nextRequestId = 1; + + [DllImport("__Internal", EntryPoint = "_GameLoversMediaSave")] + private static extern void MediaSave(string path, int isVideo, int requestId, MediaCallback callback); + + [DllImport("__Internal", EntryPoint = "_GameLoversMediaPickImage")] + private static extern void MediaPickImage(int requestId, MediaCallback callback); +#elif UNITY_ANDROID && !UNITY_EDITOR + private static readonly List _androidCallbacks = new List(); +#endif + + /// + /// Copies a file into the shared gallery. On iOS the first save shows the add-only photo prompt and needs the + /// PhotoLibraryAddOnly usage description in the Mobile Services Config. On Android 10 and newer no permission + /// is needed; older versions need the Write External Storage Legacy config toggle. + /// + /// An absolute path to an existing image or video file. + /// Whether the file is an image or a video. + /// True when the file was saved. + public static Awaitable SaveToGalleryAsync(string filePath, MediaType mediaType = MediaType.Image) + { + var completion = new AwaitableCompletionSource(); + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + Debug.LogWarning($"[GameLovers.MobileServices] SaveToGallery needs an existing file; '{filePath}' was not found."); + completion.SetResult(null); + return ToSaved(completion); + } + +#if UNITY_EDITOR + var simulatedSave = EditorSaveToGalleryOverride; + if (simulatedSave == null) + { + Debug.Log($"[GameLovers.MobileServices] SaveToGallery is not available in the editor unless the Mobile Services simulator is engaged ({filePath})."); + completion.SetResult(null); + return ToSaved(completion); + } + + completion.SetResult(simulatedSave(filePath, mediaType) ? "ok" : null); +#elif UNITY_IOS + MediaSave(filePath, mediaType == MediaType.Video ? 1 : 0, Register(completion), OnNativeResult); +#elif UNITY_ANDROID + StartAndroidSave(filePath, mediaType, completion); +#else + Debug.LogWarning("[GameLovers.MobileServices] SaveToGallery is only available on iOS and Android."); + completion.SetResult(null); +#endif + return ToSaved(completion); + } + + /// + /// Opens the system image picker and returns a copy of the chosen image. The copy lives in a temporary or cache + /// folder the app owns, so it can be loaded straight away; delete it when done. Needs no permission. + /// + /// The absolute path of the copied image, or null when the player cancelled. + public static Awaitable PickImageAsync() + { + var completion = new AwaitableCompletionSource(); +#if UNITY_EDITOR + var simulatedPick = EditorPickImageOverride; + if (simulatedPick == null) + { + Debug.Log("[GameLovers.MobileServices] PickImage is not available in the editor unless the Mobile Services simulator is engaged."); + completion.SetResult(null); + return NativeCompletion.ToMainThread(completion); + } + + completion.SetResult(simulatedPick()); +#elif UNITY_IOS + MediaPickImage(Register(completion), OnNativeResult); +#elif UNITY_ANDROID + StartAndroidPick(completion); +#else + Debug.LogWarning("[GameLovers.MobileServices] PickImage is only available on iOS and Android."); + completion.SetResult(null); +#endif + return NativeCompletion.ToMainThread(completion); + } + + private static async Awaitable ToSaved(AwaitableCompletionSource completion) + { + return await NativeCompletion.ToMainThread(completion) != null; + } + +#if UNITY_IOS && !UNITY_EDITOR + private static int Register(AwaitableCompletionSource completion) + { + lock (_requests) + { + var id = _nextRequestId++; + _requests[id] = completion; + return id; + } + } + + [AOT.MonoPInvokeCallback(typeof(MediaCallback))] + private static void OnNativeResult(int requestId, string result) + { + AwaitableCompletionSource completion; + lock (_requests) + { + if (!_requests.TryGetValue(requestId, out completion)) return; + _requests.Remove(requestId); + } + + completion.TrySetResult(result); + } +#elif UNITY_ANDROID && !UNITY_EDITOR + private static void StartAndroidSave(string filePath, MediaType mediaType, AwaitableCompletionSource completion) + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var media = new AndroidJavaClass("com.gamelovers.mobileservices.GameLoversMedia"); + var callback = new ResultProxy("com.gamelovers.mobileservices.GameLoversMedia$ResultCallback", "onResult", completion); + Retain(callback); + media.CallStatic("saveToGallery", activity, filePath, mediaType == MediaType.Video, GetAlbumName(), callback); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] SaveToGallery failed: {e.Message}"); + completion.TrySetResult(null); + } + } + + private static void StartAndroidPick(AwaitableCompletionSource completion) + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var picker = new AndroidJavaClass("com.gamelovers.mobileservices.GameLoversMediaPickerActivity"); + var callback = new ResultProxy("com.gamelovers.mobileservices.GameLoversMediaPickerActivity$PathCallback", "onPicked", completion); + Retain(callback); + picker.CallStatic("start", activity, callback); + } + catch (Exception e) + { + Debug.LogError($"[GameLovers.MobileServices] PickImage failed: {e.Message}. " + + "Turn on 'Include Media Picker' in the Mobile Services Config and rebuild."); + completion.TrySetResult(null); + } + } + + private static string GetAlbumName() + { + var name = string.IsNullOrWhiteSpace(Application.productName) ? "Game" : Application.productName.Trim(); + foreach (var invalid in new[] { '/', '\\', ':', '*', '?', '"', '<', '>', '|' }) + { + name = name.Replace(invalid, '-'); + } + return name; + } + + private static void Retain(AndroidJavaProxy callback) + { + lock (_androidCallbacks) _androidCallbacks.Add(callback); + } + + private static void Release(AndroidJavaProxy callback) + { + lock (_androidCallbacks) _androidCallbacks.Remove(callback); + } + + private sealed class ResultProxy : AndroidJavaProxy + { + private readonly string _methodName; + private readonly AwaitableCompletionSource _completion; + + public ResultProxy(string javaInterface, string methodName, AwaitableCompletionSource completion) : base(javaInterface) + { + _methodName = methodName; + _completion = completion; + } + + public override AndroidJavaObject Invoke(string methodName, object[] args) + { + if (methodName != _methodName) return base.Invoke(methodName, args); + Release(this); + _completion.TrySetResult(args != null && args.Length > 0 ? args[0] as string : null); + return null; + } + } +#endif + } +} diff --git a/Runtime/NativeUi/NativeMediaService.cs.meta b/Runtime/NativeUi/NativeMediaService.cs.meta new file mode 100644 index 0000000..5f90a5b --- /dev/null +++ b/Runtime/NativeUi/NativeMediaService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b8c8e18c1ca8144229fafd03dff42a75 \ No newline at end of file diff --git a/Runtime/NativeUi/NativeUiService.cs b/Runtime/NativeUi/NativeUiService.cs index 6167be0..4727911 100644 --- a/Runtime/NativeUi/NativeUiService.cs +++ b/Runtime/NativeUi/NativeUiService.cs @@ -2,6 +2,7 @@ #if UNITY_ANDROID using System.Collections.Generic; #endif +using System.IO; using UnityEngine; // ReSharper disable once CheckNamespace @@ -33,6 +34,11 @@ public struct AlertButton /// public static class NativeUiService { + /// Authority suffix of the package FileProvider the Android build postprocessor declares. + internal const string AndroidShareProviderAuthoritySuffix = ".gamelovers.fileprovider"; + + private const string ScreenshotFileName = "gamelovers-share-screenshot.png"; + private static readonly object _alertCompletionLock = new object(); private static AwaitableCompletionSource _currentAlertCompletion; @@ -198,6 +204,8 @@ public static void RequestReview() /// , 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 Android, sharing an image needs the package FileProvider that the Mobile Services Config's + /// Include Share FileProvider toggle declares; without it only the text is shared. /// 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) @@ -211,6 +219,75 @@ public static void Share(string text, string url = null, string imagePath = null #endif } + /// + /// Opens a web page in an in-app browser sheet over the game: SFSafariViewController on iOS and Chrome Custom + /// Tabs on Android. Use it for terms of service, patch notes or a survey, where the player should return to the game + /// with one tap. + /// + /// + /// Only http and https addresses are accepted; anything else is logged and ignored. On Android the sheet needs the + /// androidx.browser library, which the build adds unless Include Custom Tabs Dependency is turned off; + /// without it, and on other platforms, the page opens in the external browser instead. + /// + public static void OpenInAppBrowser(string url) + { + if (!IsWebUrl(url)) + { + Debug.LogWarning($"[GameLovers.MobileServices] OpenInAppBrowser needs an absolute http or https URL, got '{url}'."); + return; + } + +#if UNITY_IOS && !UNITY_EDITOR + OpenInAppBrowserNative(url); +#elif UNITY_ANDROID && !UNITY_EDITOR + OpenInAppBrowserAndroid(url); +#else + Application.OpenURL(url); +#endif + } + + /// + /// Captures the current frame and opens the OS share sheet with it. Any of , + /// and may be null. The capture happens at the end of the + /// frame, so it includes everything the player currently sees. + /// + public static async Awaitable ShareScreenshotAsync(string text = null, string url = null, string title = null) + { + await Awaitable.EndOfFrameAsync(); + var texture = ScreenCapture.CaptureScreenshotAsTexture(); + string path; + try + { + path = Path.Combine(Application.temporaryCachePath, ScreenshotFileName); + File.WriteAllBytes(path, texture.EncodeToPNG()); + } + finally + { + UnityEngine.Object.Destroy(texture); + } + + Share(text, url, path, title); + } + + /// True for an absolute http or https URL, the only kind an in-app browser can show. + internal static bool IsWebUrl(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + /// The MIME type for a shared image file, falling back to any image type. + internal static string GetImageMimeType(string imagePath) + { + var extension = Path.GetExtension(imagePath ?? string.Empty).ToLowerInvariant(); + switch (extension) + { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".gif": return "image/gif"; + case ".webp": return "image/webp"; + default: return "image/*"; + } + } + private static AlertButton[] BuildSelectionButtons(AlertButton[] buttons, Action onSelected) { var selectionButtons = new AlertButton[buttons.Length]; @@ -473,17 +550,21 @@ private static void ReleaseCurrentAndroidAlert() private static void RequestReviewAndroid() { + AndroidJavaObject activity = null; + AndroidJavaObject manager = null; try { using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); + activity = unityPlayer.GetStatic("currentActivity"); using var managerFactory = new AndroidJavaClass("com.google.android.play.core.review.ReviewManagerFactory"); - using var manager = managerFactory.CallStatic("create", activity); + manager = managerFactory.CallStatic("create", activity); using var requestTask = manager.Call("requestReviewFlow"); requestTask.Call("addOnCompleteListener", new ReviewFlowListener(activity, manager)); } catch (Exception e) { + manager?.Dispose(); + activity?.Dispose(); Debug.LogError($"[GameLovers.MobileServices] RequestReview failed: {e.Message}. " + "Ensure 'com.google.android.play:review' is on the gradle classpath."); } @@ -500,20 +581,8 @@ private static void ShareAndroid(string text, string url, string imagePath, stri 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 hasImage = !string.IsNullOrEmpty(imagePath) && TryAttachShareImage(activity, intent, intentClass, imagePath); + intent.Call("setType", hasImage ? GetImageMimeType(imagePath) : "text/plain"); var combinedText = string.IsNullOrEmpty(url) ? text : (string.IsNullOrEmpty(text) ? url : text + " " + url); if (!string.IsNullOrEmpty(combinedText)) @@ -530,6 +599,46 @@ private static void ShareAndroid(string text, string url, string imagePath, stri } } + private static void OpenInAppBrowserAndroid(string url) + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var builder = new AndroidJavaObject("androidx.browser.customtabs.CustomTabsIntent$Builder"); + using var tab = builder.Call("build"); + using var uriClass = new AndroidJavaClass("android.net.Uri"); + using var uri = uriClass.CallStatic("parse", url); + tab.Call("launchUrl", activity, uri); + } + catch (Exception e) + { + Debug.LogWarning($"[GameLovers.MobileServices] The in-app browser is unavailable ({e.Message}); opening the external browser."); + Application.OpenURL(url); + } + } + + // Android 7+ rejects file:// URIs, so the image travels through the package FileProvider. + private static bool TryAttachShareImage(AndroidJavaObject activity, AndroidJavaObject intent, AndroidJavaClass intentClass, string imagePath) + { + try + { + var authority = activity.Call("getPackageName") + AndroidShareProviderAuthoritySuffix; + using var file = new AndroidJavaObject("java.io.File", imagePath); + using var providerClass = new AndroidJavaClass("androidx.core.content.FileProvider"); + using var imageUri = providerClass.CallStatic("getUriForFile", activity, authority, file); + intent.Call("putExtra", intentClass.GetStatic("EXTRA_STREAM"), imageUri); + intent.Call("addFlags", intentClass.GetStatic("FLAG_GRANT_READ_URI_PERMISSION")); + return true; + } + catch (Exception e) + { + Debug.LogWarning($"[GameLovers.MobileServices] Sharing the image failed ({e.Message}); sharing the text only. " + + "Turn on 'Include Share FileProvider' in the Mobile Services Config and rebuild."); + return false; + } + } + private class ReviewFlowListener : AndroidJavaProxy { private readonly AndroidJavaObject _activity; @@ -567,6 +676,11 @@ public void onComplete(AndroidJavaObject task) { Debug.LogError($"[GameLovers.MobileServices] launchReviewFlow failed: {e.Message}"); } + finally + { + _manager?.Dispose(); + _activity?.Dispose(); + } } } #endif @@ -588,6 +702,9 @@ private static extern void AlertMessage(bool isSheet, string title, string messa [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversShare")] private static extern void ShareNative(string text, string url, string imagePath); + [System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "_GameLoversOpenInAppBrowser")] + private static extern void OpenInAppBrowserNative(string url); + [AOT.MonoPInvokeCallback(typeof(AlertButtonDelegate))] private static void AlertButtonCallback(string buttonText) { diff --git a/Runtime/Notifications/Android/AndroidGameNotification.cs b/Runtime/Notifications/Android/AndroidGameNotification.cs index 0a72f3a..40f3a09 100644 --- a/Runtime/Notifications/Android/AndroidGameNotification.cs +++ b/Runtime/Notifications/Android/AndroidGameNotification.cs @@ -12,12 +12,12 @@ namespace GameLovers.MobileServices.Notifications /// public class AndroidGameNotification : IGameNotification { - private AndroidNotification internalNotification; + private AndroidNotification _internalNotification; /// /// Gets the internal notification object used by the mobile notifications system. /// - public AndroidNotification InternalNotification => internalNotification; + public AndroidNotification InternalNotification => _internalNotification; /// /// @@ -26,10 +26,10 @@ public class AndroidGameNotification : IGameNotification public int? Id { get; set; } /// - public string Title { get => InternalNotification.Title; set => internalNotification.Title = value; } + public string Title { get => InternalNotification.Title; set => _internalNotification.Title = value; } /// - public string Body { get => InternalNotification.Text; set => internalNotification.Text = value; } + public string Body { get => InternalNotification.Text; set => _internalNotification.Text = value; } /// /// Does nothing on Android. @@ -47,22 +47,22 @@ public class AndroidGameNotification : IGameNotification /// public int? BadgeNumber { - get => internalNotification.Number != -1 ? internalNotification.Number : (int?)null; - set => internalNotification.Number = value ?? -1; + get => _internalNotification.Number != -1 ? _internalNotification.Number : (int?)null; + set => _internalNotification.Number = value ?? -1; } /// public bool ShouldAutoCancel { get => InternalNotification.ShouldAutoCancel; - set => internalNotification.ShouldAutoCancel = value; + set => _internalNotification.ShouldAutoCancel = value; } /// public DateTime? DeliveryTime { get => InternalNotification.FireTime; - set => internalNotification.FireTime = value ?? throw new ArgumentNullException(nameof(value)); + set => _internalNotification.FireTime = value ?? throw new ArgumentNullException(nameof(value)); } /// @@ -74,20 +74,23 @@ public DateTime? DeliveryTime public bool Scheduled { get; private set; } /// - public string SmallIcon { get => InternalNotification.SmallIcon; set => internalNotification.SmallIcon = value; } + public string SmallIcon { get => InternalNotification.SmallIcon; set => _internalNotification.SmallIcon = value; } /// - public string LargeIcon { get => InternalNotification.LargeIcon; set => internalNotification.LargeIcon = value; } + public string LargeIcon { get => InternalNotification.LargeIcon; set => _internalNotification.LargeIcon = value; } + + /// + public string Data { get => InternalNotification.IntentData; set => _internalNotification.IntentData = value; } public AndroidGameNotification() { - internalNotification = new AndroidNotification(); + _internalNotification = new AndroidNotification(); } internal AndroidGameNotification(AndroidNotification deliveredNotification, int deliveredId, string deliveredChannel) { - internalNotification = deliveredNotification; + _internalNotification = deliveredNotification; Id = deliveredId; DeliveredChannel = deliveredChannel; } diff --git a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs index 5cdceb1..efea665 100644 --- a/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs +++ b/Runtime/Notifications/Android/AndroidNotificationsPlatform.cs @@ -149,6 +149,13 @@ public void DismissAllDisplayedNotifications() AndroidNotificationCenter.CancelAllDisplayedNotifications(); } + /// + public IGameNotification GetLastOpenedNotification() + { + var data = AndroidNotificationCenter.GetLastNotificationIntent(); + return data == null ? null : new AndroidGameNotification(data.Notification, data.Id, data.Channel); + } + /// /// Does nothing on Android. /// diff --git a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs index 64cfc6f..c862947 100644 --- a/Runtime/Notifications/GameNotificationsMonoBehaviour.cs +++ b/Runtime/Notifications/GameNotificationsMonoBehaviour.cs @@ -66,8 +66,15 @@ public sealed class GameNotificationsMonoBehaviour : MonoBehaviour /// public Action OnLocalNotificationExpired; + /// + /// Event fired when the player opens a notification while the application is running or returns to it. The + /// notification that cold-started the application is exposed through . + /// + public Action OnNotificationOpened; + private IGameNotificationsPlatform _platform; private bool _inForeground = true; + private string _lastOpenedKey; /// /// Gets a collection of notifications that are scheduled or queued. @@ -79,6 +86,12 @@ public sealed class GameNotificationsMonoBehaviour : MonoBehaviour /// public bool Initialized { get; private set; } + /// + /// Gets the notification the player opened to start the application, or null when it was not started from a + /// notification. + /// + public PendingNotification LaunchNotification { get; private set; } + // Clean up platform object if necessary private void OnDestroy() { @@ -268,14 +281,12 @@ public void Initialize(params GameNotificationChannel[] channels) Initialized = true; #if UNITY_ANDROID - _platform = new AndroidNotificationsPlatform(); + var platform = new AndroidNotificationsPlatform(); // Register the notification channels var doneDefault = false; foreach (var notificationChannel in channels) { - var platform = _platform as AndroidNotificationsPlatform; - if (!doneDefault) { doneDefault = true; @@ -284,18 +295,11 @@ public void Initialize(params GameNotificationChannel[] channels) platform.RegisterChannel(notificationChannel); } + + AttachPlatform(platform); #elif UNITY_IOS - _platform = new iOSNotificationsPlatform(); + AttachPlatform(new iOSNotificationsPlatform()); #endif - - if (_platform == null) - { - return; - } - - _platform.NotificationReceived += OnNotificationReceived; - - OnForegrounding(); } /// @@ -432,6 +436,29 @@ internal PendingNotification RegisterPendingNotification(IGameNotification notif return result; } + /// Hands the host a platform and starts the foreground flow; null leaves it inert. + internal void AttachPlatform(IGameNotificationsPlatform platform) + { + Initialized = true; + _platform = platform; + + if (_platform == null) + { + return; + } + + _platform.NotificationReceived += OnNotificationReceived; + + var launchNotification = _platform.GetLastOpenedNotification(); + if (launchNotification != null) + { + LaunchNotification = new PendingNotification(launchNotification); + _lastOpenedKey = GetOpenedKey(launchNotification); + } + + OnForegrounding(); + } + #if UNITY_EDITOR /// /// Delivers one pending notification through the normal foreground callback path for editor simulation. @@ -450,8 +477,38 @@ internal bool TrySimulateDelivery(int notificationId) OnLocalNotificationDelivered?.Invoke(pendingNotification); return true; } + + /// + /// Reports a notification as opened through the normal callback path for editor simulation. + /// + internal void SimulateOpen(IGameNotification notification) + { + OnNotificationOpened?.Invoke(new PendingNotification(notification)); + } #endif + private static string GetOpenedKey(IGameNotification notification) => + notification.Id.HasValue ? notification.Id.Value.ToString() : notification.Title + "|" + notification.Body; + + // The platforms only expose the last opened notification, so a repeat read is skipped by key. + private void PublishOpenedNotification() + { + var opened = _platform.GetLastOpenedNotification(); + if (opened == null) + { + return; + } + + var key = GetOpenedKey(opened); + if (key == _lastOpenedKey) + { + return; + } + + _lastOpenedKey = key; + OnNotificationOpened?.Invoke(new PendingNotification(opened)); + } + // Event fired by _platform when a notification is received. private void OnNotificationReceived(IGameNotification deliveredNotification) { @@ -475,6 +532,7 @@ private void OnNotificationReceived(IGameNotification deliveredNotification) // Clear foreground notifications and reschedule stuff from a file private void OnForegrounding() { + PublishOpenedNotification(); PendingNotifications.Clear(); _platform.OnForeground(); diff --git a/Runtime/Notifications/IGameNotification.cs b/Runtime/Notifications/IGameNotification.cs index 07be04c..457738e 100644 --- a/Runtime/Notifications/IGameNotification.cs +++ b/Runtime/Notifications/IGameNotification.cs @@ -79,5 +79,11 @@ public interface IGameNotification /// Notification large icon. /// string LargeIcon { get; set; } + + /// + /// Gets or sets an application-defined string that travels with the notification, for example a deep-link + /// URI. It is returned when the player opens the notification. + /// + string Data { get; set; } } } diff --git a/Runtime/Notifications/Internal/EditorGameNotification.cs b/Runtime/Notifications/Internal/EditorGameNotification.cs index 8d3bc80..ce15445 100644 --- a/Runtime/Notifications/Internal/EditorGameNotification.cs +++ b/Runtime/Notifications/Internal/EditorGameNotification.cs @@ -31,5 +31,7 @@ internal class EditorGameNotification : IGameNotification public string SmallIcon { get; set; } /// public string LargeIcon { get; set; } + /// + public string Data { get; set; } } } \ No newline at end of file diff --git a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs index 0c07661..3ea1625 100644 --- a/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs +++ b/Runtime/Notifications/Internal/IGameNotificationsPlatform.cs @@ -45,6 +45,12 @@ internal interface IGameNotificationsPlatform /// void DismissAllDisplayedNotifications(); + /// + /// Gets the notification the player most recently opened, or null when none was opened. The platforms keep + /// returning the same notification until another is opened, so callers must de-duplicate. + /// + IGameNotification GetLastOpenedNotification(); + /// /// Performs any initialization or processing necessary on foregrounding the application. /// diff --git a/Runtime/Notifications/Internal/SerializableNotification.cs b/Runtime/Notifications/Internal/SerializableNotification.cs index 48048db..4714637 100644 --- a/Runtime/Notifications/Internal/SerializableNotification.cs +++ b/Runtime/Notifications/Internal/SerializableNotification.cs @@ -16,6 +16,7 @@ internal struct SerializableNotification public string Body; public string Subtitle; public string Channel; + public string Data; public bool HasBadgeNumber; public int BadgeNumber; public bool HasDeliveryTime; @@ -45,6 +46,7 @@ public static IGameNotification AsGameNotification(this SerializableNotification notification.Body = serializableNotification.Body; notification.Subtitle = serializableNotification.Subtitle; notification.Channel = serializableNotification.Channel; + notification.Data = serializableNotification.Data; notification.BadgeNumber = ReadNullableInt( serializableNotification.HasBadgeNumber, serializableNotification.BadgeNumber); @@ -72,6 +74,7 @@ public static SerializableNotification AsSerializableNotification(this PendingNo Body = source.Body, Subtitle = source.Subtitle, Channel = source.Channel, + Data = source.Data, HasBadgeNumber = source.BadgeNumber.HasValue, BadgeNumber = source.BadgeNumber ?? 0, HasDeliveryTime = deliveryTime.HasValue, diff --git a/Runtime/Notifications/MobileNotificationService.cs b/Runtime/Notifications/MobileNotificationService.cs index 6178c72..8f8b98e 100644 --- a/Runtime/Notifications/MobileNotificationService.cs +++ b/Runtime/Notifications/MobileNotificationService.cs @@ -23,6 +23,18 @@ public interface INotificationService /// event Action OnLocalNotificationExpiredEvent; + /// + /// Event fired when the player opens a notification while the application is running or when it returns to + /// the foreground. The notification that started the application is read with + /// instead. Read to route the tap, + /// for example into a deep-link router. + /// + /// + /// Opens are detected when the application gains focus. A later notification that reuses the id of the last + /// opened one is not reported again, so leave the id unset or unique. + /// + event Action OnNotificationOpened; + /// /// The queueing / delivery for the service. Defaults to /// . Settable at runtime; the new value takes effect on the @@ -35,6 +47,13 @@ public interface INotificationService /// IReadOnlyList PendingNotifications { get; } + /// + /// Gets the notification the player opened to start the application. + /// + /// The launching notification, or null when the application was not started from one. + /// True when the application was started by opening a notification. + bool TryGetLaunchNotification(out PendingNotification notification); + /// /// Create a new instance of a for this platform. /// @@ -83,6 +102,8 @@ public class MobileNotificationService : INotificationService, IDisposable public event Action OnLocalNotificationDeliveredEvent; /// public event Action OnLocalNotificationExpiredEvent; + /// + public event Action OnNotificationOpened; /// public IReadOnlyList PendingNotifications @@ -127,11 +148,20 @@ public MobileNotificationService(params GameNotificationChannel[] channels) // assigning the event's backing field here would snapshot it while still null and never see a subscriber. _monoBehaviour.OnLocalNotificationDelivered = notification => OnLocalNotificationDeliveredEvent?.Invoke(notification); _monoBehaviour.OnLocalNotificationExpired = notification => OnLocalNotificationExpiredEvent?.Invoke(notification); + _monoBehaviour.OnNotificationOpened = notification => OnNotificationOpened?.Invoke(notification); _monoBehaviour.Initialize(_channels); UnityEngine.Object.DontDestroyOnLoad(_monoBehaviour); } + /// + public bool TryGetLaunchNotification(out PendingNotification notification) + { + ThrowIfDisposed(); + notification = _monoBehaviour.LaunchNotification; + return notification != null; + } + /// public IGameNotification CreateNotification() { @@ -202,6 +232,7 @@ public void Dispose() _disposed = true; OnLocalNotificationDeliveredEvent = null; OnLocalNotificationExpiredEvent = null; + OnNotificationOpened = null; if (_monoBehaviour == null) { @@ -227,6 +258,15 @@ internal bool TrySimulateDelivery(int notificationId) ThrowIfDisposed(); return _monoBehaviour.TrySimulateDelivery(notificationId); } + + /// + /// Reports a notification as opened through the editor simulator. + /// + internal void SimulateOpen(IGameNotification notification) + { + ThrowIfDisposed(); + _monoBehaviour.SimulateOpen(notification); + } #endif private void ThrowIfDisposed() diff --git a/Runtime/Notifications/NotificationBuilder.cs b/Runtime/Notifications/NotificationBuilder.cs index 4f6135f..c23bf58 100644 --- a/Runtime/Notifications/NotificationBuilder.cs +++ b/Runtime/Notifications/NotificationBuilder.cs @@ -74,6 +74,12 @@ internal NotificationBuilder(INotificationService service) /// public NotificationBuilder In(TimeSpan delay) { _notification.DeliveryTime = DateTime.Now + delay; return this; } + /// + /// Attaches an application-defined string, for example a deep-link URI, that is returned when the player + /// opens the notification. + /// + public NotificationBuilder Data(string data) { _notification.Data = data; return this; } + /// /// Marks the notification for badge-increment behaviour. The underlying /// auto-increments badge numbers when none of the diff --git a/Runtime/Notifications/iOS/iOSGameNotification.cs b/Runtime/Notifications/iOS/iOSGameNotification.cs index 155b7b8..3b9e3e7 100644 --- a/Runtime/Notifications/iOS/iOSGameNotification.cs +++ b/Runtime/Notifications/iOS/iOSGameNotification.cs @@ -13,12 +13,12 @@ namespace GameLovers.MobileServices.Notifications /// public class iOSGameNotification : IGameNotification { - private readonly iOSNotification internalNotification; + private readonly iOSNotification _internalNotification; /// /// Gets the internal notification object used by the mobile notifications system. /// - public iOSNotification InternalNotification => internalNotification; + public iOSNotification InternalNotification => _internalNotification; /// /// @@ -29,7 +29,7 @@ public int? Id { get { - if (!int.TryParse(internalNotification.Identifier, out int value)) + if (!int.TryParse(_internalNotification.Identifier, out int value)) { Debug.LogWarning("Internal iOS notification's identifier isn't a number."); return null; @@ -44,18 +44,18 @@ public int? Id throw new ArgumentNullException(nameof(value)); } - internalNotification.Identifier = value.Value.ToString(); + _internalNotification.Identifier = value.Value.ToString(); } } /// - public string Title { get => internalNotification.Title; set => internalNotification.Title = value; } + public string Title { get => _internalNotification.Title; set => _internalNotification.Title = value; } /// - public string Body { get => internalNotification.Body; set => internalNotification.Body = value; } + public string Body { get => _internalNotification.Body; set => _internalNotification.Body = value; } /// - public string Subtitle { get => internalNotification.Subtitle; set => internalNotification.Subtitle = value; } + public string Subtitle { get => _internalNotification.Subtitle; set => _internalNotification.Subtitle = value; } /// /// @@ -67,8 +67,8 @@ public int? Id /// public int? BadgeNumber { - get => internalNotification.Badge != -1 ? internalNotification.Badge : (int?)null; - set => internalNotification.Badge = value ?? -1; + get => _internalNotification.Badge != -1 ? _internalNotification.Badge : (int?)null; + set => _internalNotification.Badge = value ?? -1; } /// @@ -90,7 +90,7 @@ public DateTime? DeliveryTime { get { - if (!(internalNotification.Trigger is iOSNotificationCalendarTrigger calendarTrigger)) + if (!(_internalNotification.Trigger is iOSNotificationCalendarTrigger calendarTrigger)) { return null; } @@ -118,7 +118,7 @@ public DateTime? DeliveryTime DateTime date = value.Value.ToLocalTime(); - internalNotification.Trigger = new iOSNotificationCalendarTrigger + _internalNotification.Trigger = new iOSNotificationCalendarTrigger { Year = date.Year, Month = date.Month, @@ -135,8 +135,8 @@ public DateTime? DeliveryTime /// public string CategoryIdentifier { - get => internalNotification.CategoryIdentifier; - set => internalNotification.CategoryIdentifier = value; + get => _internalNotification.CategoryIdentifier; + set => _internalNotification.CategoryIdentifier = value; } /// @@ -149,17 +149,20 @@ public string CategoryIdentifier /// public string LargeIcon { get => null; set {} } + /// + public string Data { get => _internalNotification.Data; set => _internalNotification.Data = value; } + public iOSGameNotification() { - internalNotification = new iOSNotification + _internalNotification = new iOSNotification { ShowInForeground = true // Deliver in foreground by default }; } - internal iOSGameNotification(iOSNotification internalNotification) + internal iOSGameNotification(iOSNotification _internalNotification) { - this.internalNotification = internalNotification; + this._internalNotification = _internalNotification; } /// diff --git a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs index 4d49303..7ebb9a8 100644 --- a/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs +++ b/Runtime/Notifications/iOS/iOSNotificationsPlatform.cs @@ -90,6 +90,13 @@ public void DismissAllDisplayedNotifications() iOSNotificationCenter.RemoveAllDeliveredNotifications(); } + /// + public IGameNotification GetLastOpenedNotification() + { + var notification = iOSNotificationCenter.GetLastRespondedNotification(); + return notification == null ? null : new iOSGameNotification(notification); + } + /// /// Clears badge count. /// diff --git a/Samples~/MobileServicesSamples/DeepLinkRouter/DeepLinkRouter.uxml b/Samples~/MobileServicesSamples/DeepLinkRouter/DeepLinkRouter.uxml index a669bab..1246c31 100644 --- a/Samples~/MobileServicesSamples/DeepLinkRouter/DeepLinkRouter.uxml +++ b/Samples~/MobileServicesSamples/DeepLinkRouter/DeepLinkRouter.uxml @@ -1,7 +1,7 @@ - +