IDeviceService is an umbrella facade aggregating six independently mockable sub-services. Each child interface is also independently registerable for testing. Stateless conveniences (screen wake, opening OS Settings, opening the store page, home-screen shortcuts) are available directly on DeviceService.
IDeviceService device = new DeviceService();
device.Battery.OnLowPowerModeChanged += () =>
Debug.Log($"LPM -> {device.Battery.IsLowPowerMode}");
DeviceService.KeepAwake = true;
device.AudioSession.ConfigureForPlayback();
var camera = await device.Permissions.RequestAsync(AppPermission.Camera);
var att = await device.Att.RequestAuthorizationAsync();
device.DeepLink.OnLinkActivated += uri => Debug.Log($"Deep link: {uri}");| Property | Interface | What it does |
|---|---|---|
Battery |
IBatteryService |
Level + status + low-power-mode events (iOS NSProcessInfoPowerStateDidChangeNotification, Android PowerManager.isPowerSaveMode). |
AudioSession |
IIosAudioSessionService |
iOS AVAudioSession category override (silent-switch). No-op on Android / Editor. |
Permissions |
IPermissionsService |
Unified iOS+Android runtime permissions. Task-based async. Multi-permission overload. |
Att |
IAttService |
iOS App Tracking Transparency. Direct ATTrackingManager bridge — no com.unity.ads.ios-support dependency. |
DeepLink |
IDeepLinkService |
Application.deepLinkActivated wrapper with cold-start link queueing. |
BackgroundTasks |
IBackgroundTaskService |
Extra execution time when the app moves to the background (iOS); pass-through on Android. |
Additionally, an IDeepLinkRouter / DeepLinkRouter layered on IDeepLinkService provides path-pattern routing — see below.
DeviceService.KeepAwake is static sugar over Unity's Screen.sleepTimeout:
DeviceService.KeepAwake = true; // SleepTimeout.NeverSleep
DeviceService.KeepAwake = false; // SleepTimeout.SystemSettingBecause the setting is process-wide, it is not part of the injectable IDeviceService child graph.
DeviceService.OpenStorePage(iosAppId: "123456789"); // App Store / Google Play page
DeviceService.OpenStorePage("123456789", writeReview: true); // iOS write-review sheet
DeviceService.OpenAppSettings(); // this app's OS Settings pageOpenStorePage builds an https://apps.apple.com/app/id... or https://play.google.com/store/apps/details?id=... link and opens it with Application.OpenURL, so the store app handles it when installed. On iOS the numeric App Store id is required (with or without an id prefix); on Android androidPackage defaults to Application.identifier. Android has no review deep link, so writeReview opens the store page there. In the Editor it logs the link instead of opening it. This is a link, not the in-app review prompt: use NativeUiService.RequestReview() for that.
DeviceService.SetShortcuts(new[]
{
new AppShortcut("daily", "Daily reward", "mygame://rewards/daily"),
new AppShortcut("versus", "Play versus", "mygame://play/versus", subtitle: "Ranked match"),
});
// Taps arrive as ordinary deep links, including as the cold-start link when the tap launched the app.
device.DeepLink.OnLinkActivated += uri => router.TryDispatch(uri);
DeviceService.ClearShortcuts();SetShortcuts replaces the quick actions shown when the player long-presses the app icon. Each AppShortcut has a stable Id, a Title, an absolute Uri and an optional Subtitle. Taps are delivered through IDeepLinkService, so the same router that handles web links handles shortcuts.
- Limits — at most four shortcuts are kept. Entries without an id, title or absolute URI, and repeated ids, are skipped with a log message.
- Android — dynamic shortcuts through
ShortcutManager(API 25+, otherwise a logged no-op). Each shortcut launches this app's activity with anACTION_VIEWintent carrying the URI, which Unity reports as a deep link. Shortcuts use the app icon. - iOS —
UIApplication.shortcutItems. The package hooksapplication:performActionForShortcutItem:completionHandler:onUnityAppControllerat load time and chains to any implementation another SDK already installed. A tapped shortcut is held natively and collected whenDeepLinkServiceis constructed and whenever the app returns to the foreground. The hook covers the app-delegate lifecycle; it does not see shortcuts delivered through a UIScene lifecycle. - Cold start — construct
DeepLinkServiceearly, as for web links: a shortcut tapped before anyone subscribed is replayed to the first subscriber. - Editor — setting shortcuts logs; drive taps with
EditorPlatformSimulator.SimulateDeepLink(uri, yourService).
using var task = device.BackgroundTasks.Begin("upload-save", onExpiring: () => cancel.Cancel());
await Task.Run(() => UploadSave(cancel.Token)); // runs off the main threadIBackgroundTaskService.Begin(name, onExpiring) asks iOS for extra execution time (roughly 30 seconds) when the app moves to the background and returns a BackgroundTaskHandle. Dispose the handle as soon as the work finishes; leaving it open past the limit lets the system terminate the app.
- Run the work off the main thread. Unity's main loop is paused while backgrounded, so
Awaitableand other main-thread continuations do not resume until the player returns. Use a thread-pool task or native I/O. onExpiringis raised on the main thread from the system's expiration handler, immediately before the task ends. Keep it to setting a flag or cancelling a token.- The handle's
IsExpired,IsActiveandRemainingTimeare readable from any thread, so the protected work can pollIsExpired.RemainingTimeisTimeSpan.MaxValuein the foreground. - Android and Editor: the handle is a pass-through (always active, unlimited). Android keeps the process alive for a short grace period but offers no per-task extension; use a foreground service or WorkManager outside this package for long work.
Every event-driven child shares a single internal MonoBehaviour (DeviceServicesHost, DontDestroyOnLoad, lazily spawned). One RegisterLateUpdate / RegisterSecondTick / RegisterFocusChanged / RegisterIosLowPowerModeChanged fan-out drives the entire device subsystem. The runtime cost of the entire Device subsystem is one auto-spawned GameObject — not one per service.
DeviceServicesHost.ResetForTests() is exposed for EditMode test teardown.
public enum AppPermission { Camera, Microphone, LocationWhenInUse, LocationAlways, PhotoLibrary, PhotoLibraryAddOnly, Notifications }
public enum PermissionStatus { NotDetermined, Denied, Granted, Restricted, Limited }
PermissionStatus Check(AppPermission permission); // sync, no prompt
Task<PermissionStatus> RequestAsync(AppPermission permission); // async, may prompt
// Multi-permission convenience — awaits sequentially (iOS prompts cannot stack).
Task<IReadOnlyDictionary<AppPermission, PermissionStatus>> RequestAsync(params AppPermission[] permissions);iOS callbacks land at PermissionsCallbackReceiver (auto-spawned DontDestroyOnLoad GameObject) via UnitySendMessage("PermissionsCallbackReceiver", "OnPermissionResult", "<id>:<status>"). Android uses Unity's UnityEngine.Android.Permission API.
Treat Granted and Limited as usable access. Limited means partial access: selected photos only (iOS 14+ and Android 14+), provisional notifications (iOS), approximate location (iOS reduced accuracy, Android coarse-only), or When-In-Use location after LocationAlways was requested.
- Location —
LocationWhenInUseandLocationAlwaysask for precise and approximate location together, because Android 12+ ignores a precise-only request.LocationAlwaysthen makes a second, separate request forACCESS_BACKGROUND_LOCATIONonce foreground access is granted (API 29+). Foreground-only access reportsLimited. - Photos —
READ_MEDIA_IMAGESon API 33+, plusREAD_MEDIA_VISUAL_USER_SELECTEDon API 34+ so a partial selection reportsLimited;READ_EXTERNAL_STORAGEbelow API 33.PhotoLibraryAddOnlyneeds no permission on API 29+ and reportsGranted. - Notifications —
POST_NOTIFICATIONSon API 33+. Below API 33 there is nothing to request:CheckreportsGrantedorDeniedfrom the user's notifications-enabled switch, andRequestAsyncreturns that status without a prompt. DeniedversusNotDetermined— Android never reports a refusal directly. The service remembers (inPlayerPrefs) that it requested a permission and also reads the OS rationale flag, so a refused permission reportsDeniedand an untouched oneNotDetermined. A refused permission often cannot be requested again.- Requests are serialized because Android shows one system dialog at a time.
When a permission is Denied, explain why it is needed (for example with ShowAlertPopUpAsync) and call DeviceService.OpenAppSettings(). It opens this app's page in iOS Settings or Android App Info. The game should Check again when it returns to the foreground.
- Notification status is cached natively (refreshed at startup, on every foreground return, and after each request) so
Checknever blocks. Only the very first call after launch can wait up to 250 ms for the initial read. - Only permission groups whose usage description is present in the generated
Info.plistare compiled into the iOS binary (camera, microphone, location, photos read/write, photos add-only) (see build-configuration.md). Requesting an undeclared group returnsNotDeterminedand logs a message instead of calling an API Apple would flag as missing its purpose string. LocationAlwaysrequested while When-In-Use is already granted returnsLimitedimmediately; iOS shows its own upgrade prompt later, soCheckagain after the app returns to the foreground.
Manifest entries are auto-injected by the build postprocessor when the matching Android manifest toggle is on — see build-configuration.md.
public enum AttStatus { NotDetermined, Restricted, Denied, Authorized }
AttStatus CurrentStatus { get; }
Task<AttStatus> RequestAuthorizationAsync();- iOS 14.5+ — bridges
ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler:viaPlugins/iOS/Att.m. - iOS < 14.5 / Android / Editor / unsupported — returns
Authorized. Don't read this as "the user authorized" — read it as "the platform doesn't apply ATT". Conditionalize tracking-init code onApplication.platform == RuntimePlatform.IPhonePlayerif you care about the distinction.
public interface IDeepLinkService
{
event Action<Uri> OnLinkActivated; // runtime + cold-start replay
Uri PendingColdStartLink { get; } // null after first consume / never set
}The cold-start link (captured from Application.absoluteURL at construction) is replayed to the first subscriber only — subsequent subscribers do NOT receive it. Construct the service early in app bootstrap (before scene load).
For pattern-based routing rather than a giant switch in your OnLinkActivated handler:
public interface IDeepLinkRouter
{
void MapRoute(string pathPattern, Action<Uri, IReadOnlyDictionary<string, string>> handler);
void RemoveRoute(string pathPattern);
bool TryDispatch(Uri uri);
}using var router = new DeepLinkRouter(device.DeepLink, routes =>
{
routes.MapRoute("/promo/:id", (uri, p) => OpenPromo(p["id"]));
});
router.MapRoute("/profile/:userId", (uri, p) => OpenProfile(p["userId"]));
router.MapRoute("/settings", (uri, p) => OpenSettings());Literal segments match exactly (case-insensitive); :name segments capture into the params dict. Routes are checked in registration order — first match wins. The router subscribes once to OnLinkActivated at construction.
Notification taps can feed the same router: attach a deep-link URI with NotificationBuilder.Data(...) and call router.TryDispatch(new Uri(opened.Notification.Data)) from OnNotificationOpened — see notifications.md.
For safe-area UI padding, use com.gamelovers.uiservice (SafeAreaPanelView/SafeAreaContainer).