Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Watch app source code

A simple SwiftUI app on the watch that asks the phone for settings and collects AWARE sensors.

Copy MarkdownOpen inOpen in GitHubView as MarkdownOpen in Scira AIOpen in ChatGPTOpen in ClaudeOpen in Cursor

This is an example app. Replace the files that Xcode generated in ios/CarpWatch Watch App/ with the three files below.

iosCarpWatch Watch AppCarpWatchSensorController.swiftCarpWatchApp.swiftContentView.swift

CarpWatchSensorController.swift

The controller has four main parts:

PartRoleWhat it does
activate()StartupActivates the WCSession, restores whether sampling was running when the app was last closed, sets the chunk format, asks the phone for settings, and resumes sampling if it was running. The columnar chunk format is 3–5× smaller than row-oriented JSON, which directly reduces radio time on both devices.
apply(settings:)Configuration handshakeMaps the keys the phone sends onto the AWARE sensor configurations. AWARE itself applies label, db_host and debug to every registered sensor. Everything else is applied here, because only the watch app knows which sensors exist. See Watch settings.
start() / stop()Sensor lifecycleBuilds the set of enabled sensors, requests HealthKit authorization if heart rate is among them, and starts the background session chosen by the phone.
transferNow()Sending data to the phoneRuns on a timer at fileTransferInterval, and whenever the app goes to the background. WCSession.transferFile queues the files, so this works even when the phone is out of range.
CarpWatchSensorController.swift
import Foundation
import SwiftUI
import WatchConnectivity

import com_awareframework_ios_core
import com_awareframework_ios_sensor_applewatch_shared
import com_awareframework_ios_sensor_applewatch_watchOS

/// Runs the AWARE sensors on the watch, configured by the paired iPhone.
///
/// The phone answers a `get_settings` request with the `AppleWatchDevice`
/// configuration from the deployed CARP study protocol, so this controller
/// holds no study-specific configuration of its own - only defaults used until
/// the phone has been heard from.
@MainActor
final class CarpWatchSensorController: NSObject, ObservableObject {

    // MARK: - Published state (for the UI)

    @Published private(set) var isRunning = false
    @Published private(set) var statusMessage = "Not started"
    @Published private(set) var activeSensorCount = 0
    @Published private(set) var lastTransferAt: Date?
    @Published private(set) var isPhoneReachable = false

    /// The AWARE device id of this watch. Handy to show in a debug screen.
    let deviceId = AwareUtils.getCommonDeviceId()

    // MARK: - Settings received from the phone

    private var backgroundSessionType: AWBackgroundSessionType = .microphone
    private var transferInterval: TimeInterval = 15 * 60
    private var transferIncrementally = true
    private var deleteAfterTransfer = true

    private var enabled: [String: Bool] = [
        "motion": true,
        "battery": true,
        "device": true,
        "heartRate": true,
        "location": false,
        "heading": false,
        "bluetooth": false,
        "audio": false,
    ]

    private var transferTimer: Timer?
    private var hasAppliedPhoneSettings = false

    // MARK: - Sensors

    private lazy var motion = AWMotionSensor(AWMotionSensor.Config().apply { config in
        config.motionSensorHz = 10
        config.saveIntervalSeconds = 10
        config.activateAccelerometerSensor = true
        config.activateDeviceMotionSensor = true
        // Gyroscope and magnetometer are covered by device motion - leaving
        // them on doubles the data volume for very little extra information.
        config.activateGyroscopeSensor = false
        config.activateMagnetometerSensor = false
    })

    private lazy var battery = AWBatterySensor(AWBatterySensor.Config().apply { config in
        config.intervalSeconds = 60
    })

    private lazy var device = AWDeviceSensor(AWDeviceSensor.Config())

    private lazy var heartRate = AWHeartRateSensor(AWHeartRateSensor.Config())

    private lazy var location = AWLocationSensor(AWLocationSensor.Config())

    private lazy var heading = AWHeadingSensor(AWHeadingSensor.Config())

    private lazy var bluetooth = AWBluetoothSensor(AWBluetoothSensor.Config().apply { config in
        config.sensingDurationSeconds = 10
        config.sleepDurationSeconds = 50
    })

    private lazy var audio = AWAudioSensor(AWAudioSensor.Config().apply { config in
        config.activateAmbientNoiseSensor = true
        config.activateAudioClassificationSensor = true
        // Never store raw audio - the phone side of this package does not
        // accept it, and a study that records audio needs a very different
        // consent process.
        config.activateRawAudioSensor = false
        config.dutyCycleEnabled = true
        config.activeDuration = 60
        config.restDuration = 180
        config.storeOnlyTopK = 5
    })

    // MARK: - Lifecycle

    /// Called when the watch app appears. Resumes sampling if it was running
    /// when the app was last closed, and asks the phone for the study settings.
    func activate() {
        _ = AWWCSessionManager.shared          // activates the WCSession
        isRunning = UserDefaults.standard.bool(forKey: Self.isRunningKey)
        updateReachability()

        // Chunk size and format only matter for how the data is packed; leave
        // the columnar format on, it is 3-5x smaller than row-oriented JSON.
        AWDataTransferManager.shared.useColumnarFormat = true
        AWDataTransferManager.shared.recordsPerChunk = 500

        applyPhoneSettings()
        if isRunning { start() }
    }

    /// Ask the phone for the study configuration and apply it.
    ///
    /// Safe to call at any time - if the phone cannot be reached the call
    /// simply has no effect, and the sensors keep running with whatever
    /// configuration they already have.
    func applyPhoneSettings() {
        guard WCSession.default.isReachable else {
            statusMessage = hasAppliedPhoneSettings
                ? statusMessage
                : "Waiting for the phone…"
            return
        }

        AWWCSessionManager.shared.applyiPhoneSettings { [weak self] settings in
            Task { @MainActor in self?.apply(settings: settings) }
        }
    }

    /// Map the settings sent by `AppleWatchDevice` onto the AWARE sensors.
    private func apply(settings: [String: Any]) {
        // `applyiPhoneSettings` already applied `label`, `db_host`, and `debug`
        // to every sensor registered in AWSensorManager. Everything below has
        // to be applied by hand, because only we know which sensors exist.

        if let hz = settings["motion_sensor_hz"] as? Int {
            motion.CONFIG.motionSensorHz = hz
        }
        if let value = settings["watch_motion_accelerometer_enabled"] as? Bool {
            motion.CONFIG.activateAccelerometerSensor = value
        }
        if let value = settings["watch_motion_device_motion_enabled"] as? Bool {
            motion.CONFIG.activateDeviceMotionSensor = value
        }

        if let value = settings["watch_audio_ambient_noise_enabled"] as? Bool {
            audio.CONFIG.activateAmbientNoiseSensor = value
        }
        if let value = settings["watch_audio_classification_enabled"] as? Bool {
            audio.CONFIG.activateAudioClassificationSensor = value
        }
        if let value = settings["watch_audio_duty_cycle_enabled"] as? Bool {
            audio.CONFIG.dutyCycleEnabled = value
        }
        if let value = settings["watch_audio_active_duration"] as? Double {
            audio.CONFIG.activeDuration = value
        }
        if let value = settings["watch_audio_rest_duration"] as? Double {
            audio.CONFIG.restDuration = value
        }

        enabled["motion"] = settings["watch_motion_enabled"] as? Bool ?? enabled["motion"]!
        enabled["battery"] = settings["watch_battery_enabled"] as? Bool ?? enabled["battery"]!
        enabled["device"] = settings["watch_device_enabled"] as? Bool ?? enabled["device"]!
        enabled["heartRate"] = settings["watch_healthkit_enabled"] as? Bool ?? enabled["heartRate"]!
        enabled["location"] = settings["watch_location_enabled"] as? Bool ?? enabled["location"]!
        enabled["heading"] = settings["watch_heading_enabled"] as? Bool ?? enabled["heading"]!
        enabled["bluetooth"] = settings["watch_bluetooth_enabled"] as? Bool ?? enabled["bluetooth"]!
        enabled["audio"] = settings["watch_audio_enabled"] as? Bool ?? enabled["audio"]!

        backgroundSessionType = AWBackgroundSessionType(
            rawValueOrDefault: settings["watch_background_session_type"] as? String
        )

        if let seconds = settings["file_transfer_interval_seconds"] as? Double, seconds > 0 {
            transferInterval = seconds
        }
        transferIncrementally =
            (settings["watch_transfer_mode"] as? String ?? "incremental") == "incremental"
        deleteAfterTransfer = settings["watch_delete_after_transfer"] as? Bool ?? true

        hasAppliedPhoneSettings = true
        statusMessage = "Configured by the phone"

        // Restart so the new configuration takes effect right away.
        if isRunning { start() }
    }

    // MARK: - Start / stop

    func start() {
        let sensors = selectedSensors()
        guard !sensors.isEmpty else {
            stop()
            statusMessage = "No sensors enabled"
            return
        }

        AWSensorManager.shared.set(sensors: sensors) { [weak self] in
            guard let self else { return }

            // Asks for HealthKit authorization if a heart rate sensor is in the
            // set. On watchOS the prompt is shown on the watch.
            AWSensorManager.shared.requestPermissionHealthKit { _, _ in }

            AWSensorManager.shared.start(backgroundSessionType: self.backgroundSessionType) {
                Task { @MainActor in
                    self.isRunning = true
                    self.activeSensorCount = sensors.count
                    self.statusMessage = "Collecting from \(sensors.count) sensors"
                    self.persistRunningState()
                    self.scheduleTransfers()
                    self.updateReachability()
                }
            }
        }
    }

    func stop() {
        transferTimer?.invalidate()
        transferTimer = nil

        AWSensorManager.shared.stop { [weak self] in
            Task { @MainActor in
                guard let self else { return }
                self.isRunning = false
                self.activeSensorCount = 0
                self.statusMessage = "Stopped"
                self.persistRunningState()
            }
        }
    }

    func toggle() {
        isRunning ? stop() : start()
    }

    private func selectedSensors() -> [AwareSensor] {
        var sensors: [AwareSensor] = []
        if enabled["motion"] == true { sensors.append(motion) }
        if enabled["battery"] == true { sensors.append(battery) }
        if enabled["device"] == true { sensors.append(device) }
        if enabled["heartRate"] == true { sensors.append(heartRate) }
        if enabled["location"] == true { sensors.append(location) }
        if enabled["heading"] == true { sensors.append(heading) }
        if enabled["bluetooth"] == true { sensors.append(bluetooth) }
        if enabled["audio"] == true { sensors.append(audio) }
        return sensors
    }

    // MARK: - Transferring data to the phone

    private func scheduleTransfers() {
        transferTimer?.invalidate()
        transferTimer = Timer.scheduledTimer(
            withTimeInterval: transferInterval,
            repeats: true
        ) { [weak self] _ in
            Task { @MainActor in self?.transferNow() }
        }
    }

    /// Hand everything collected so far to the phone.
    ///
    /// `WCSession.transferFile` queues the files, so this works even when the
    /// phone is out of range - the OS delivers them when it comes back.
    func transferNow() {
        let sensors = selectedSensors()
        guard !sensors.isEmpty else { return }

        statusMessage = "Transferring…"

        let completion: (Error?) -> Void = { [weak self] error in
            Task { @MainActor in
                guard let self else { return }
                if let error {
                    self.statusMessage = "Transfer failed: \(error.localizedDescription)"
                } else {
                    self.lastTransferAt = Date()
                    self.statusMessage = self.isRunning
                        ? "Collecting from \(self.activeSensorCount) sensors"
                        : "Transfer complete"
                }
                self.updateReachability()
            }
        }

        if transferIncrementally {
            AWSensorManager.shared.transferIncrementalData(
                deleteAfterTransfer: deleteAfterTransfer,
                completion: completion
            )
        } else {
            AWSensorManager.shared.transferAllData(
                deleteAfterTransfer: deleteAfterTransfer,
                completion: completion
            )
        }
    }

    // MARK: - Helpers

    private static let isRunningKey = "carp_watch_is_running"

    private func persistRunningState() {
        UserDefaults.standard.set(isRunning, forKey: Self.isRunningKey)
    }

    private func updateReachability() {
        isPhoneReachable = WCSession.isSupported() && WCSession.default.isReachable
    }
}

CarpWatchApp.swift

The app entry point activates the controller when the view appears, and calls transferNow() when the app moves to the background.

CarpWatchApp.swift
import SwiftUI

@main
struct CarpWatchApp: App {
    @StateObject private var controller = CarpWatchSensorController()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView(controller: controller)
                .onAppear { controller.activate() }
        }
        .onChange(of: scenePhase) { _, phase in
            // Hand whatever has been collected to the phone before the app is
            // suspended, so nothing is left behind if the app is terminated.
            if phase == .background { controller.transferNow() }
        }
    }
}

Note

On watchOS 9 and earlier, onChange(of:) takes a single-parameter closure: .onChange(of: scenePhase) { phase in ... }.

ContentView.swift

The status screen shows whether the watch is collecting, the controller's status message, whether the phone is reachable and the time of the last transfer. It has Start/Stop, Send data to phone and Reload settings buttons.

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject var controller: CarpWatchSensorController

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 10) {
                Label(
                    controller.isRunning ? "Collecting" : "Stopped",
                    systemImage: controller.isRunning ? "waveform.path.ecg" : "pause.circle"
                )
                .font(.headline)

                Text(controller.statusMessage)
                    .font(.footnote)
                    .foregroundStyle(.secondary)

                Label(
                    controller.isPhoneReachable ? "Phone reachable" : "Phone out of range",
                    systemImage: controller.isPhoneReachable ? "iphone.radiowaves.left.and.right" : "iphone.slash"
                )
                .font(.caption2)
                .foregroundStyle(.secondary)

                if let lastTransferAt = controller.lastTransferAt {
                    Text("Last transfer: \(lastTransferAt.formatted(date: .omitted, time: .shortened))")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }

                Button(controller.isRunning ? "Stop" : "Start") {
                    controller.toggle()
                }
                .tint(controller.isRunning ? .red : .green)

                Button("Send data to phone") {
                    controller.transferNow()
                }

                Button("Reload settings") {
                    controller.applyPhoneSettings()
                }
                .font(.footnote)
            }
            .padding(.horizontal, 4)
        }
    }
}

Custom settings

To pass a setting the package does not expose, such as a sensor threshold:

  1. Send it from Dart

    Add the key in AppleWatchDevice.toWatchSettings(). Keys the native AppleWatchSensor.Config does not recognize are forwarded to the watch verbatim.

  2. Read it on the watch

    Pick it up in apply(settings:) and apply it to the relevant sensor configuration.

Adding a setting of your own shows the Swift side.

Next step

Build and verify

Install the app on the watch and follow the data end to end.

Quick links

Page · DocumentationCARP DocumentationPage · DocumentationIntroductionPage · DocumentationQuickstartPage · DocumentationTerminologyPage · DocumentationArchitecture