Skip to content

skiptools/skip-device

Repository files navigation

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:

  1. Create a provider instance (retain it for the lifetime of the monitoring session)
  2. Optionally set updateInterval before calling monitor()
  3. Iterate the AsyncThrowingStream returned by monitor()
  4. 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:

  1. Fork this repository and enable actions from the "Actions" tab
  2. Check out your fork locally
  3. When developing alongside a Skip app, add the package to a shared workspace to see your changes incorporated in the app
  4. Push your changes to your fork and ensure the CI checks all pass in the Actions tab
  5. Add your name to the Skip Contributor Agreement
  6. 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.

About

Support for device hardware and sensors for Skip apps

Resources

Contributing

Stars

Watchers

Forks

Releases

Sponsor this project

Used by

Contributors

Languages