SkipDevice
The SkipDevice module is a dual-platform Skip framework that provides access to network reachability, device identity, location services, app runtime events, finite background activity, and device sensor data (accelerometer, gyroscope, magnetometer, and barometer).
On Apple platforms, the module wraps platform APIs such as
UIKit,
CoreMotion,
CoreLocation, and
SystemConfiguration.
On Android, it wraps platform APIs such as
Build,
Application,
Service,
SensorManager,
LocationManager, and
ConnectivityManager.
All sensor providers expose a unified AsyncThrowingStream interface that works identically on both platforms.
Setup
To include this framework in your project, add the following
dependency to your Package.swift file:
let package = Package( name: "my-package", products: [ .library(name: "MyProduct", targets: ["MyTarget"]), ], dependencies: [ .package(url: "https://source.skip.dev/skip-device.git", "0.0.0"..<"2.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ .product(name: "SkipDevice", package: "skip-device") ]) ] )
Sensor Usage Pattern
All sensor providers follow the same pattern:
- Create a provider instance (retain it for the lifetime of the monitoring session)
- Optionally set
updateIntervalbefore callingmonitor() - Iterate the
AsyncThrowingStreamreturned bymonitor() - The stream automatically stops when the task is cancelled or the provider is deallocated
let provider = SomeProvider() provider.updateInterval = 0.1 // optional, in seconds do { for try await event in provider.monitor() { // process event } } catch { // handle error }
Check provider.isAvailable before starting to determine if the hardware is present on the device.
Network Reachability
Check whether the device currently has network access.
| iOS | Android | |
|---|---|---|
| API | SCNetworkReachability | ConnectivityManager |
import SkipDevice let isReachable = NetworkReachability.isNetworkReachable
Network Reachability Permissions
| Platform | Requirement |
|---|---|
| iOS | No permission required |
| Android | Declare ACCESS_NETWORK_STATE in AndroidManifest.xml |
Android manifest entry:
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Device Identity
Read low-level device identity fields from the current platform.
| iOS / tvOS | Android | |
|---|---|---|
| API | UIDevice |
Build, Settings.Global, Settings.Secure |
import SkipDevice let identity = DeviceIdentity.current print(identity.name ?? "Unnamed device") print(identity.model ?? "Unknown model")
vendorIdentifier maps to UIDevice.identifierForVendor on Apple platforms and Settings.Secure.ANDROID_ID on Android. Treat it as privacy-sensitive app data; app-specific policy, disclosure, and storage choices remain app-owned.
DeviceIdentity Properties
| Property | Description |
|---|---|
name |
User-visible device name when the platform exposes one |
model |
Platform model string |
localizedModel |
Localized Apple model string when available |
systemName |
Platform operating system name |
systemVersion |
Platform operating system version |
vendorIdentifier |
App/vendor-scoped stable identifier when available |
manufacturer |
Device manufacturer, such as Apple, Google, or Samsung |
brand |
Android Build.BRAND when available |
device |
Android Build.DEVICE when available |
product |
Android Build.PRODUCT when available |
Location
Access the device's geographic location via GPS, network, and fused providers. Provides latitude, longitude, altitude, speed, course, and accuracy information.
| iOS | Android | |
|---|---|---|
| API | CLLocationManager | LocationManager (FUSED_PROVIDER) |
Single Location Request
import SkipDevice let provider = LocationProvider() let location = try await provider.fetchCurrentLocation() print("lat: \(location.latitude), lon: \(location.longitude), alt: \(location.altitude)")
Continuous Location Updates
import SwiftUI import SkipKit // for PermissionManager import SkipDevice struct LocationView: View { @State var event: LocationEvent? @State var errorMessage: String? var body: some View { VStack { if let event = event { Text("Latitude: \(event.latitude)") Text("Longitude: \(event.longitude)") Text("Altitude: \(event.altitude) m") Text("Speed: \(event.speed) m/s") Text("Course: \(event.course)") Text("Accuracy: \(event.horizontalAccuracy) m") } else if let errorMessage = errorMessage { Text(errorMessage).foregroundStyle(.red) } else { ProgressView() } } .task { let status = await PermissionManager.requestLocationPermission(precise: true, always: false) guard status.isAuthorized == true else { errorMessage = "Location permission denied" return } let provider = LocationProvider() do { for try await event in provider.monitor() { self.event = event } } catch { errorMessage = "\(error)" } } } }
LocationEvent Properties
| Property | Type | Description |
|---|---|---|
latitude |
Double |
Latitude in degrees |
longitude |
Double |
Longitude in degrees |
horizontalAccuracy |
Double |
Horizontal accuracy in meters |
altitude |
Double |
Altitude (Mean Sea Level) in meters |
ellipsoidalAltitude |
Double |
Ellipsoidal altitude in meters |
verticalAccuracy |
Double |
Vertical accuracy in meters |
speed |
Double |
Speed in meters per second |
speedAccuracy |
Double |
Speed accuracy in meters per second |
course |
Double |
Course/bearing in degrees |
courseAccuracy |
Double |
Course accuracy in degrees |
timestamp |
TimeInterval |
Event timestamp |
Location Permissions
Location requires both a metadata declaration and a runtime permission request on both platforms. Use SkipKit's PermissionManager for cross-platform runtime permission handling.
| Platform | Requirement |
|---|---|
| iOS | Declare NSLocationWhenInUseUsageDescription in Darwin/AppName.xcconfig |
| Android | Declare ACCESS_FINE_LOCATION and/or ACCESS_COARSE_LOCATION in AndroidManifest.xml |
| Both | Request permission at runtime via PermissionManager.requestLocationPermission() |
iOS xcconfig entry:
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This app uses your location to …"
Android manifest entries:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
Application Runtime Events
Monitor app lifecycle and memory pressure events through a single API on both platforms.
| iOS / tvOS | Android | |
|---|---|---|
| Lifecycle API | UIApplication notifications |
Application.ActivityLifecycleCallbacks |
| Memory API | UIApplication.didReceiveMemoryWarningNotification |
ComponentCallbacks2 |
import SkipDevice let provider = ApplicationRuntimeProvider() Task { for await event in provider.monitorLifecycle() { print("event: \(event.kind.rawValue), phase: \(event.phase.rawValue)") } } Task { for await event in provider.monitorMemoryPressure() { print("memory pressure: \(event.level.rawValue)") } }
event.kind preserves an iOS-style lifecycle event name where possible, while event.phase gives callers a normalized foreground/background phase. Call provider.stop() when the owning feature no longer needs runtime events. Unsupported Apple platforms compile and report .unknown lifecycle phase with no platform callbacks.
Android memory pressure maps onLowMemory, TRIM_MEMORY_RUNNING_CRITICAL, and TRIM_MEMORY_COMPLETE to .critical; other trim-memory pressure callbacks map to .warning.
Runtime Event Values
| Type | Values |
|---|---|
ApplicationLifecyclePhase |
active, inactive, background, terminated, unknown |
ApplicationLifecycleEventKind |
didBecomeActive, willResignActive, didEnterBackground, willTerminate, unknown |
MemoryPressureLevel |
warning, critical |
monitorLifecycle() immediately yields the most recently known lifecycle event. On Android this is initially .unknown until an activity lifecycle callback is observed. On Apple platforms, the initial phase is read from UIApplication.shared.applicationState when available on the main thread; otherwise it starts as .unknown.
Background Activity
Begin and end finite user-visible background work. This is not a guarantee of indefinite execution: the app still owns completing work promptly and ending the activity.
import SkipDevice let identifier = try await BackgroundActivity.begin(BackgroundActivityRequest( name: "Syncing media", reason: BackgroundActivityReason.localNetworkTransfer, detail: "Keeping the transfer active" )) await performTransfer() await BackgroundActivity.end(identifier)
Use do / catch or task cancellation handling in app code so BackgroundActivity.end(_:) runs on success, failure, and cancellation.
BackgroundActivityRequest Properties
| Property | Default | Description |
|---|---|---|
name |
Required | User-visible activity name |
reason |
shortCriticalWork |
Platform reason used to choose the Android foreground-service type |
detail |
Empty string | Optional user-visible detail for the Android foreground notification |
notificationChannelID |
tools.skip.device.background_activity |
Android notification channel identifier |
notificationID |
41001 |
Android foreground notification identifier |
notificationIconResourceName |
ic_notification |
Android drawable resource name for the foreground notification icon |
Background Activity Reasons
| Reason | Android foreground service type |
|---|---|
localNetworkTransfer |
dataSync |
mediaProcessing |
mediaProcessing on Android 15+, dataSync on older Android versions |
connectedDeviceTransfer |
connectedDevice |
shortCriticalWork |
shortService when available, dataSync on older Android versions |
On iOS and tvOS, BackgroundActivity wraps UIApplication.beginBackgroundTask(withName:expirationHandler:) and UIApplication.endBackgroundTask(_:).
On Android, BackgroundActivity starts skip.device.BackgroundActivityService as a foreground service. Android 15 limits dataSync and mediaProcessing foreground services to 6 hours per 24 hours; the service implements Service.onTimeout(int, int) and stops promptly when Android reports a timeout.
Background Activity Android Manifest
Apps using BackgroundActivity must declare the foreground service and the service-type permissions needed by their chosen reasons:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" /> <application> <service android:name="skip.device.BackgroundActivityService" android:exported="false" android:foregroundServiceType="dataSync|mediaProcessing|connectedDevice|shortService" /> </application>
shortService does not have a type-specific permission, but it still requires FOREGROUND_SERVICE. connectedDevice has additional Android runtime prerequisites depending on the device transport, such as Bluetooth, NFC, USB, or network-change capabilities. The app owns any extra runtime permissions required for its use case.
The default Android notification icon resource is ic_notification. Apps can provide a different drawable resource through BackgroundActivityRequest.notificationIconResourceName. Notification text, icon design, Android notification permission flow, and Google Play foreground-service policy justification remain app-owned.
Motion Sensors
The accelerometer, gyroscope, magnetometer, and barometer share a common iOS permission requirement and usage pattern. On Android, motion sensors do not require any runtime permissions.
Motion Permissions
| Platform | Requirement |
|---|---|
| iOS | Declare NSMotionUsageDescription in Darwin/AppName.xcconfig (no runtime request needed) |
| Android | No permission required for accelerometer, gyroscope, or magnetometer. Barometer requires a <uses-feature> declaration. |
iOS xcconfig entry:
INFOPLIST_KEY_NSMotionUsageDescription = "This app uses motion sensors to …"
Accelerometer
Measures acceleration force on three axes in G's (gravitational force units, where 1G = 9.81 m/s). At rest face-up, the device reports approximately (0, 0, -1) G.
| iOS | Android | |
|---|---|---|
| API | CMMotionManager.startAccelerometerUpdates | Sensor.TYPE_ACCELEROMETER |
| Units | G's | m/s (converted to G's by SkipDevice) |
import SwiftUI import SkipDevice struct AccelerometerView: View { @State var event: AccelerometerEvent? var body: some View { VStack { if let event = event { Text("X: \(event.x) G") Text("Y: \(event.y) G") Text("Z: \(event.z) G") } } .task { let provider = AccelerometerProvider() guard provider.isAvailable else { return } provider.updateInterval = 0.1 do { for try await event in provider.monitor() { self.event = event } } catch { logger.error("accelerometer error: \(error)") } } } }
AccelerometerEvent Properties
| Property | Type | Description |
|---|---|---|
x |
Double |
X-axis acceleration in G's |
y |
Double |
Y-axis acceleration in G's |
z |
Double |
Z-axis acceleration in G's |
timestamp |
TimeInterval |
Event timestamp (seconds since boot) |
Gyroscope
Measures angular rotation rate on three axes in radians per second.
| iOS | Android | |
|---|---|---|
| API | CMMotionManager.startGyroUpdates | Sensor.TYPE_GYROSCOPE |
| Units | rad/s | rad/s |
import SwiftUI import SkipDevice struct GyroscopeView: View { @State var event: GyroscopeEvent? var body: some View { VStack { if let event = event { Text("X: \(event.x) rad/s") Text("Y: \(event.y) rad/s") Text("Z: \(event.z) rad/s") } } .task { let provider = GyroscopeProvider() guard provider.isAvailable else { return } provider.updateInterval = 0.1 do { for try await event in provider.monitor() { self.event = event } } catch { logger.error("gyroscope error: \(error)") } } } }
GyroscopeEvent Properties
| Property | Type | Description |
|---|---|---|
x |
Double |
Angular speed around the x-axis in rad/s |
y |
Double |
Angular speed around the y-axis in rad/s |
z |
Double |
Angular speed around the z-axis in rad/s |
timestamp |
TimeInterval |
Event timestamp (seconds since boot) |
Magnetometer
Measures the ambient magnetic field on three axes in microteslas. Returns calibrated values with device bias removed on both platforms. Useful for compass headings and magnetic field detection.
| iOS | Android | |
|---|---|---|
| API | CMDeviceMotion.magneticField (calibrated) | Sensor.TYPE_MAGNETIC_FIELD (calibrated) |
| Units | microteslas | microteslas |
Earth's magnetic field strength is typically 25-65 microteslas. Both platforms return calibrated geomagnetic field values with the device's own magnetic bias (hard iron distortion) removed.
import SwiftUI import SkipDevice struct MagnetometerView: View { @State var event: MagnetometerEvent? var heading: Double { guard let event = event else { return 0 } let angle = atan2(event.y, event.x) * 180.0 / .pi return angle < 0 ? angle + 360 : angle } var body: some View { VStack { if let event = event { Text("X: \(event.x) uT") Text("Y: \(event.y) uT") Text("Z: \(event.z) uT") Text("Heading: \(heading)") } } .task { let provider = MagnetometerProvider() guard provider.isAvailable else { return } provider.updateInterval = 0.1 do { for try await event in provider.monitor() { self.event = event } } catch { logger.error("magnetometer error: \(error)") } } } }
MagnetometerEvent Properties
| Property | Type | Description |
|---|---|---|
x |
Double |
X-axis magnetic field in microteslas |
y |
Double |
Y-axis magnetic field in microteslas |
z |
Double |
Z-axis magnetic field in microteslas |
timestamp |
TimeInterval |
Event timestamp (seconds since boot) |
Barometer
Measures atmospheric pressure in kilopascals (kPa) and tracks relative altitude changes in meters since monitoring began.
| iOS | Android | |
|---|---|---|
| API | CMAltimeter | Sensor.TYPE_PRESSURE |
| Pressure units | kPa | hPa (converted to kPa by SkipDevice) |
| Altitude | Relative meters since start | Computed via SensorManager.getAltitude |
Standard atmospheric pressure at sea level is approximately 101.325 kPa.
import SwiftUI import SkipDevice struct BarometerView: View { @State var event: BarometerEvent? var body: some View { VStack { if let event = event { Text("Pressure: \(event.pressure) kPa") Text("Relative altitude: \(event.relativeAltitude) m") } } .task { let provider = BarometerProvider() guard provider.isAvailable else { return } provider.updateInterval = 0.5 do { for try await event in provider.monitor() { self.event = event } } catch { logger.error("barometer error: \(error)") } } } }
BarometerEvent Properties
| Property | Type | Description |
|---|---|---|
pressure |
Double |
Atmospheric pressure in kilopascals (kPa) |
relativeAltitude |
Double |
Altitude change in meters since monitoring started |
timestamp |
TimeInterval |
Event timestamp |
Barometer Permissions
| Platform | Requirement |
|---|---|
| iOS | NSMotionUsageDescription (same as other motion sensors) |
| Android | Declare sensor feature in AndroidManifest.xml |
Android manifest entry:
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
Set android:required="false" so the app can still be installed on devices without a barometer.
Permissions Summary
| Capability | iOS Declaration | iOS Runtime | Android Declaration | Android Runtime |
|---|---|---|---|---|
| Network Reachability | None | None | ACCESS_NETWORK_STATE |
None |
| Device Identity | None | None | None | None |
| Location | NSLocationWhenInUseUsageDescription |
Yes (via PermissionManager) |
ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION |
Yes (via PermissionManager) |
| Application Runtime Events | None | None | None | None |
| Background Activity | None | None | FOREGROUND_SERVICE plus selected foreground-service type permissions |
App-owned by use case |
| Accelerometer | NSMotionUsageDescription |
None | None | None |
| Gyroscope | NSMotionUsageDescription |
None | None | None |
| Magnetometer | NSMotionUsageDescription |
None | None | None |
| Barometer | NSMotionUsageDescription |
None | uses-feature (barometer) |
None |
API Reference
| API | Event / Value Type | Key Properties | isAvailable |
updateInterval |
|---|---|---|---|---|
NetworkReachability |
-- | .isNetworkReachable: Bool (static) |
-- | -- |
DeviceIdentity |
DeviceIdentity |
.current, name, model, system, vendor, Android build fields |
-- | -- |
LocationProvider |
LocationEvent |
latitude, longitude, altitude, speed, course, accuracy | Yes | No (1s default) |
ApplicationRuntimeProvider |
ApplicationLifecycleEvent, MemoryPressureEvent |
lifecycle phase/kind, memory pressure level | -- | -- |
BackgroundActivity |
BackgroundActivityRequest |
begin(_:), end(_:), reason, notification metadata |
-- | -- |
AccelerometerProvider |
AccelerometerEvent |
x, y, z (G's) | Yes | Yes |
GyroscopeProvider |
GyroscopeEvent |
x, y, z (rad/s) | Yes | Yes |
MagnetometerProvider |
MagnetometerEvent |
x, y, z (microteslas) | Yes | Yes |
BarometerProvider |
BarometerEvent |
pressure (kPa), relativeAltitude (m) | Yes | Yes |
Sensor providers share the same interface:
| Method / Property | Description |
|---|---|
init() |
Create a provider instance |
isAvailable: Bool |
Whether the sensor hardware is present |
updateInterval: TimeInterval? |
Set before calling monitor() |
monitor() -> AsyncThrowingStream |
Start streaming sensor events |
stop() |
Stop monitoring (also called automatically on deinit and task cancellation) |
Building
This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.
Testing
The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.
Contributing
We welcome contributions to this package in the form of enhancements and bug fixes.
The general flow for contributing to this and any other Skip package is:
- Fork this repository and enable actions from the "Actions" tab
- Check out your fork locally
- When developing alongside a Skip app, add the package to a shared workspace to see your changes incorporated in the app
- Push your changes to your fork and ensure the CI checks all pass in the Actions tab
- Add your name to the Skip Contributor Agreement
- Open a Pull Request from your fork with a description of your changes
License
This software is licensed under the Mozilla Public License 2.0.