From a269dc375ed9b8e13bdbf25021cc672b48c3c94d Mon Sep 17 00:00:00 2001 From: Foxpace Date: Thu, 27 Aug 2026 22:08:39 +0200 Subject: [PATCH 1/4] docs: define recording architecture and review path --- AGENTS.MD | 61 ++++++++ CONTEXT.md | 23 +++ README.md | 27 ++-- ...-recording-engine-from-android-adapters.md | 8 +- ...nate-paired-recordings-with-protocol-v2.md | 5 - ...fine-sensorbox-application-architecture.md | 11 ++ ...006-prefer-direct-user-intent-execution.md | 13 ++ docs/architecture-refactor-plan.md | 117 --------------- docs/recording-architecture-guided-review.md | 142 ++++++++++++++++++ 9 files changed, 270 insertions(+), 137 deletions(-) create mode 100644 AGENTS.MD create mode 100644 CONTEXT.md delete mode 100644 docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md create mode 100644 docs/adr/0005-define-sensorbox-application-architecture.md create mode 100644 docs/adr/0006-prefer-direct-user-intent-execution.md delete mode 100644 docs/architecture-refactor-plan.md create mode 100644 docs/recording-architecture-guided-review.md diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..a82ab8c --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,61 @@ +# VoidLauncher + +## Project + +This project is a SensorBox app, which records raw Android and Wear OS sensor samples to local CSV files. The app uses Android's system folder picker, and recordings never require cloud storage or an account. + +The intent of the project is to provide a simple and reliable way to record sensor data from Android and Wear OS devices, while maintaining user privacy and control over their data. + + +## Coding style + +The simplicity is above all. The code should be simple, readable and verbose +enough that it is simple to understand. Methods and logic parts should be small enough to skim through +and have nuanced gaps between them. + +Everything is named by the intent of the user, and only major contracts can have names +containing technical terms. Practically, the navigation in code is the navigation of the user. + +The app uses MVI architecture - UI is dumb, calls the ViewModel, and it orchestrates data sources and APIs. +The ViewModel comes back with a state that is rendered by the UI. The state is immutable and contains all the data needed to render the UI. + +Every screen is its own module and cross-cutting concerns are in their own modules. +The modules are small and have a single responsibility. +Every screen has root composable, where the viewmodel and UI meet ends. +The UI only talks to viewmodel, not repos. Repos and usecases are part of the viewmodels. +UI is dumb and only renders the state. Root is responsible for delegating actions, events and +native side effects - it can use other classes to do so. + +## Testing + +Every ViewModel and repo has its own tests on unit level. +Fakes are only permitted and mocks only for third party implemenations, which +would be hard to fake. + +Tests follow the same coding style as the main code and BDD - descriptions are composed of +Given, When, Then and test contains these comments too as its id divided. + +Fixtures are used to create conditions for test executiona and ideally should be reused +across tests. + +The tests should be simple and readable, ideally only one line per statement in the description of the test. + +Run only tests, which are required and at the end of large implementation, run the whole test suite. + +## Terminology + +- Screen / Root Screen - a screen is a module that contains all the code for a single screen. It has its own ViewModel, UI and repos. It is responsible for rendering the state and delegating actions to the ViewModel. +- State - a state is an immutable data class that contains all the data needed to render the UI. It is returned by the ViewModel and rendered by the UI. +- Action - an action is a user interaction that is delegated to the ViewModel. It can be a button click, a gesture, a keyboard shortcut, etc. +- User intent - a user intent is a high-level action that the user wants to perform. It is translated into one or more actions that are delegated to the ViewModel. Naming is based on user intent, not technical terms. For example, "Open App" is a user intent, while "LaunchActivity" is a technical term. Contract between layers can be merged with technical term. +- Contract - defines communication between layers. Techncial terms can be used for these classes too. +- Repo - a repo is a class that is responsible for fetching data from a data source. It can be a local database, a remote API, or any other data source. It is used by the ViewModel to fetch data and return it as a state. +- Usecase - a usecase is a class that contains business logic and orchestrates data from multiple repos. It is used by the ViewModel to perform complex actions and return a state. It equals to a user intention to do something. + +## People + +You are agent. +Me as user I am programmer, and we are building this project in collaboration. +Explain your ideas, ask questions, give suggestions and think about possible architectural improvements +based on the current state of the project. Review the code and give feedback. +Be straight and go to the point. \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..bd96788 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,23 @@ +# Recording archive + +The recording archive contains measurements created on the phone or copied from Wear OS. + +## Language + +**Measurement** + +One recording session stored as a named folder in the selected recording directory. + +_Avoid:_ Recording file, sensor measurement + +**Measurement file** + +One data stream inside a measurement, such as accelerometer samples or GPS coordinates. + +_Avoid:_ Measurement + +**Measurement metadata** + +Session-level facts stored with a measurement, including its start time, notes, alarms, duration, and sensor ranges. + +_Avoid:_ Sensor metadata diff --git a/README.md b/README.md index d1886a7..61c7505 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ Modules: - `recording-core`: pure Kotlin recording state machine, source roles, scheduling, and cleanup policy. - `core`: Android DataStore preferences, document storage, local rotating diagnostics, and reusable test fixtures. - `sensorservices`: Android recording adapters, foreground host, and linear sensor/GPS writers. It has no Wear dependency. -- `WearOsLib`: coroutine-based connectivity, strict protocol v3 command encoding, and Channel file transport. App policy stays in `app` and `wear`. +- `WearOsLib`: coroutine-based connectivity, strict protocol v5 JSON commands, and Channel file transport. App policy stays in `app` and `wear`. -Paired phone/watch recording is all-or-nothing: both sides prepare before either commits, commands are session-correlated and idempotent, timeouts use bounded retries, and rejection or timeout compensates both sides. +Paired phone/watch recording starts directly on each device. Commands are session-correlated and idempotent, and both devices own their local duration timer after starting. A lost connection does not stop an active recording; peer stop notifications are best effort. ## Platform and toolchain @@ -96,24 +96,27 @@ Tests use Given/When/Then naming, reusable state/repository fixtures, coroutine ## Emulator integration tests -The phone sensor test starts the real foreground measurement service, injects three accelerometer values through the emulator console, and verifies the generated CSV: +The phone recording tests start the real foreground measurement service, read the device sensors, control test GPS and battery state from Kotlin, and verify the generated files. Run the class directly from Android Studio or with Gradle: ```shell -ANDROID_HOME="$HOME/Library/Android/sdk" \ -PHONE_SERIAL=emulator-5554 \ -tools/emulator/run_phone_sensor_test.sh +ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \ + "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest" ``` -The Wear sync test sends a fixture CSV through the real Wear OS Channel API and verifies its exact bytes on the phone. Use an Android 17 Google Play phone AVD and a Wear OS 7 AVD. Pair them once with Android Studio's Pairing Assistant and complete the Wear companion flow before running: +Standalone Wear recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone: ```shell -ANDROID_HOME="$HOME/Library/Android/sdk" \ -PHONE_SERIAL=emulator-5554 \ -WEAR_SERIAL=emulator-5556 \ -tools/emulator/run_wear_sync_test.sh +ANDROID_SERIAL=emulator-5554 ./gradlew :wear:connectedDebugAndroidTest \ + "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.WearSensorRecordingEmulatorTest" ``` -The runner creates Android Studio's ADB forward/reverse bridge and fails immediately with pairing guidance when the watch reports no peer. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected Storage Access Framework directory. +The paired sync matrix sends CSV, JSON, text, empty, Unicode, overwrite, duplicate-name, ignored-extension, and 256 KiB fixtures through the real Wear OS Channel API. The phone verifies every destination and byte. Use a Google Play phone AVD and a Wear OS AVD, then pair them once with Android Studio's Pairing Assistant: + +```shell +ANDROID_HOME="$HOME/Library/Android/sdk" tools/emulator/run_wear_sync_test.sh +``` + +The runner detects one phone and one watch automatically; `PHONE_SERIAL` and `WEAR_SERIAL` remain available when several devices are connected. It builds and installs once, refreshes the ADB bridge after installation, and launches each scenario on both devices. When an emulator transport exposes its paired node but does not propagate static capabilities, the instrumentation-only repository falls back to that connected node; file transfer still uses the production Channel client and receiver. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected Storage Access Framework directory. No Firebase project, Maps key, secrets file, or external storage permission is required. diff --git a/docs/adr/0002-separate-recording-engine-from-android-adapters.md b/docs/adr/0002-separate-recording-engine-from-android-adapters.md index e967f9e..5bd4ee2 100644 --- a/docs/adr/0002-separate-recording-engine-from-android-adapters.md +++ b/docs/adr/0002-separate-recording-engine-from-android-adapters.md @@ -1,5 +1,7 @@ -# Separate the recording engine from Android adapters +# Separate recording execution from Android and Wear adapters -Recording state, scheduling policy, source coordination, cleanup, and terminal events live in a pure Kotlin `recording-core` module. Android sensor capture, foreground hosting, notifications, wake locks, battery callbacks, GPS, and storage streams remain adapters in `sensorservices`. +Recording execution, source ordering, failure cleanup, duration limits, and terminal events live in the pure Kotlin `recording-core` module. Callers submit a complete recording plan through direct start and stop operations. Android sensor capture, foreground hosting, notifications, wake locks, battery callbacks, GPS, and storage streams remain in `sensorservices`. -This split keeps the recording state machine testable without Android and prevents Wear transport policy from entering the local recording module. +The phone owns coordination when one user recording includes phone and watch sources. `WearOsLib` owns serializable command models, JSON encoding, connection discovery, and byte transport. The phone and watch applications own command behavior. Session commands are idempotent, bounded by timeouts, and identified by one recording session ID. + +This split keeps local recording execution independent of Android and Wear transport. A failed start stops every selected source so no sensor continues recording, but the application does not maintain a prepare, commit, abort, or rollback protocol. diff --git a/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md b/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md deleted file mode 100644 index 616fb88..0000000 --- a/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md +++ /dev/null @@ -1,5 +0,0 @@ -# Coordinate paired recordings with protocol version 2 - -Paired phone and watch recording uses a breaking, session-identified prepare, commit, abort, and stop protocol. Both sides must prepare before either side commits. Commands are idempotent, bounded by timeouts, and retried with the same session ID. - -The phone owns the paired workflow. `WearOsLib` owns typed protocol encoding and transport, while phone and watch applications own command behavior. Unsupported protocol versions fail explicitly. diff --git a/docs/adr/0005-define-sensorbox-application-architecture.md b/docs/adr/0005-define-sensorbox-application-architecture.md new file mode 100644 index 0000000..4dbf0b6 --- /dev/null +++ b/docs/adr/0005-define-sensorbox-application-architecture.md @@ -0,0 +1,11 @@ +# Define the SensorBox application architecture + +SensorBox organizes code by user workflow and keeps platform code at the edge. `core-common` owns application outcomes and shared clocks. `core` owns reusable Android storage, preferences, and diagnostics. `recording-core` executes a complete recording plan without Android dependencies. `sensorservices` turns that plan into Android sensor and storage work. `WearOsLib` owns Wear command models and Google Play transport. `app` and `wear` own presentation and the phone or watch behavior behind received commands. + +Each screen owns immutable state, user actions, effects, its ViewModel, and the use cases needed by that ViewModel. Root screens connect Compose to the ViewModel and perform native effects. Child UI renders state and reports user actions. Application navigation follows user workflows rather than technical tiers. + +Expected failures cross module interfaces as `AppResult` with stable `AppErrorCode` values. Infrastructure adapters translate platform exceptions. Diagnostics remain local to the device. Tests use fakes at real seams and assert the outcomes seen by production callers. + +CSV is reserved for recorded sample streams. Other structured data that crosses a process boundary or is stored on disk uses Kotlin serialization with explicit serializable models. This includes Wear commands, Wear file metadata, measurement metadata, and diagnostic entries. JSON codecs live beside their models and reject malformed input at the boundary. + +Dependencies point toward policy. Android and Google Play types do not enter `recording-core`, protocol models, application outcomes, or presentation state. Phone and watch applications may depend on Android adapters and Wear transport, while those lower modules do not depend on application presentation or paired-recording policy. diff --git a/docs/adr/0006-prefer-direct-user-intent-execution.md b/docs/adr/0006-prefer-direct-user-intent-execution.md new file mode 100644 index 0000000..5f3c6ed --- /dev/null +++ b/docs/adr/0006-prefer-direct-user-intent-execution.md @@ -0,0 +1,13 @@ +# Prefer direct user-intent execution + +SensorBox favors direct execution over staged technical protocols. Recording starts from one complete plan and stops from one explicit reason. Phone and watch coordination uses start and stop commands. Current workflow state rejects duplicate or conflicting user intents, so orchestration does not add a mutex when one owner already serializes those intents. + +Interfaces exist for external dependencies or roles with multiple meaningful adapters. A one-off interface used only to fake a pass-through class is removed. Internal construction uses factories when an Android lifecycle entry point must assemble several session objects. Factory and method names state which user operation or domain object they create. + +Recording source adapters live in separate files. They do not retain an Android `Context`; an operation receives the current context or a factory uses it while constructing the session. Model conversions, validation, serialization, and factories live beside their models instead of inside orchestration methods. + +Wear commands are serializable data classes encoded as versioned JSON on both phone and watch. Sensor discovery is an explicit `RequestAvailableSensors` command answered by `AvailableSensors`. ViewModels own presentation state and delegate storage selection, permission checks, Wear discovery, recording control, and session observation to user-intent use cases. + +A delayed start is presentation behavior. The recording ViewModel exposes and advances the countdown, then calls the recording use case after it reaches zero. No folder, service, recording source, or Wear start command is created while the countdown is running. + +After a paired start reaches each device, each foreground service owns its recording independently. The phone copies the same duration into the Wear start command, and both services pass that duration to their own `RecordingEngine`. Each engine schedules its own local duration stop. Losing the phone-to-Wear connection does not stop either recording or cancel either timer. If the Wear start command was sent but its result is lost, the phone keeps recording; an explicit Wear rejection still fails and cleans up the paired start. When a local timer expires, that device finishes first and treats the peer stop notification as best effort. A delivery failure cannot reopen the finished session or prevent another recording from starting. diff --git a/docs/architecture-refactor-plan.md b/docs/architecture-refactor-plan.md deleted file mode 100644 index 2d1fbff..0000000 --- a/docs/architecture-refactor-plan.md +++ /dev/null @@ -1,117 +0,0 @@ -# SensorBox architecture refactor plan - -Status: implemented and verified on 2026-08-19. The phone emulator workflow passes. The Wear-to-phone emulator workflow is built but awaits a one-time Android Studio phone/watch pairing. - -This file is the implementation source of truth for the Android 17 hard-cut refactor. Update the status boxes as work lands. Do not preserve an old interface merely because a later step still uses it. Each completed step must leave the repository in a valid intermediate state. - -Related decisions: - -- [0001: Use explicit application outcomes](adr/0001-use-explicit-application-outcomes.md) -- [0002: Separate the recording engine from Android adapters](adr/0002-separate-recording-engine-from-android-adapters.md) -- [0003: Coordinate paired recordings with protocol version 2](adr/0003-coordinate-paired-recordings-with-protocol-v2.md) -- [0004: Own presentation state by user workflow](adr/0004-own-presentation-state-by-user-workflow.md) - -## Completion rules - -- [x] Every module interface uses `AppResult` for expected outcomes. Kotlin `Result` does not cross a module interface. -- [x] Every intermediate implementation state compiles and passes the checks relevant to its changes. -- [ ] The final state passes unit tests, Detekt, phone and watch lint, Android test compilation, and both emulator workflows. -- [x] Tests cross the same interfaces used by production callers. -- [x] Diagnostics remain local until the user copies or shares them. -- [x] `sensorservices` has no dependency on `wearoslib`. -- [x] Unsupported Wear protocol versions fail explicitly. No compatibility layer remains. - -## Phase 1: outcomes and diagnostics - -- [x] Replace exception-backed `AppError` with a data-only error. -- [x] Add stable error codes for connectivity, external actions, measurement, permission, preferences, storage, validation, timeout, conflict, and unknown failures. -- [x] Add `AppResult.Success` and `AppResult.Failure` plus composition helpers. -- [x] Translate expected exceptions at infrastructure adapters and rethrow coroutine cancellation. -- [x] Add `DiagnosticLogger` for writes and `DiagnosticsStore` for read, export, and clear operations. -- [x] Store structured, redacted diagnostics in app-private rotating files. -- [x] Keep the current and previous diagnostic file at no more than 1 MB each. -- [x] Expose view, copy, share-text, share-file, and clear actions from Settings. -- [x] Add tests for outcome composition, cancellation, redaction, rotation, and logger write failure. - -Diagnostic entries may contain timestamps, severity, stable codes, operations, app/build information, Android/device model information, process names, session IDs, lifecycle states, stop reasons, source types, protocol versions, retry counts, sanitized exception classes, stack traces, durations, source counts, and sampling periods. - -Diagnostic entries must not contain samples, coordinates, annotations, command payloads, full recording names, complete file names, SAF URIs, or persistent device identifiers. - -## Phase 2: pure recording engine - -- [x] Add a pure Kotlin `recording-core` module. -- [x] Define typed recording plans, session IDs, source types, stop reasons, states, commands, and terminal events. -- [x] Define the small source role used internally by the engine. -- [x] Prepare and start sources in stable order. -- [x] On start failure, stop all prepared sources in reverse order. -- [x] On stop, attempt every active source and combine failures. -- [x] Make repeated stop idempotent. -- [x] Emit typed stop events for duration expiry, low battery, source failure, platform destruction, and user requests. -- [x] Test partial start, reverse cleanup, repeated stop, timed stop, low-battery stop, and combined cleanup failure. - -The durable state sequence is `Idle`, `Preparing`, `Prepared`, `Running`, and `Stopping`. Terminal results are `RecordingStarted`, `RecordingStartRejected`, and `RecordingStopped` events. The engine returns to `Idle` after a terminal event. - -## Phase 3: Android recording adapters - -- [x] Keep Android sensors, GPS, activity recognition, significant motion, storage streams, alarms, wake locks, battery callbacks, notifications, and foreground hosting in `sensorservices`. -- [x] Replace `Bundle` source configuration with typed configuration. -- [x] Make `MeasurementService` translate intents into engine commands. -- [x] Publish engine state through an application-scoped read-only session repository. -- [x] Deliver typed session events to application-provided observers. -- [x] Remove Wear commands, codecs, connection collaborators, and paired flags from `sensorservices`. -- [x] Remove the `sensorservices -> wearoslib` Gradle dependency. - -## Phase 4: Wear protocol version 2 - -- [x] Add session identity to every paired recording command. -- [x] Add prepare, commit, abort, stop, and correlated acknowledgement messages with typed outcomes. -- [x] Make handlers idempotent and return the prior outcome for duplicate commands. -- [x] Reject a command for a conflicting active session with `CONFLICT`. -- [x] Reject unsupported protocol versions. -- [x] Keep command models, encoding, decoding, connection discovery, and byte transport in `WearOsLib`. -- [x] Add round-trip, unsupported-version, duplicate-command, and malformed-payload tests. - -## Phase 5: paired recording workflow - -- [x] Put phone and watch coordination in one phone-side workflow. -- [x] Prepare both sides before committing either side. -- [x] Give prepare 10 seconds and commit acknowledgement 5 seconds. -- [x] Retry each message twice with the same session ID. -- [x] Abort both sides after rejection or final timeout. -- [x] Never fall back to phone-only recording when the plan contains watch sources. -- [x] Propagate every automatic local stop to the watch through the typed session observer. -- [x] Stop both sides even if one stop fails, then return a combined outcome. -- [x] Test remote rejection, prepare timeout, commit compensation, duplicate messages, and partial stop failure. - -## Phase 6: presentation ownership - -- [x] Make the application shell own navigation and only the session facts needed for navigation and screen-awake policy. -- [x] Give onboarding its own state, actions, effects, and ViewModel. -- [x] Give the full recording journey one navigation-graph-scoped ViewModel. -- [x] Give settings its own state, actions, effects, and ViewModel. -- [x] Keep child routes stateless and pass focused state and actions. -- [x] Move recording request construction, validation, permission resolution, and start effects into the recording feature. -- [x] Test ViewModel actions, state transitions, effects, and representative error codes. - -The recording journey includes sensor selection, sensor details, live preview, measurement setup, permission resolution, active recording, and annotations. - -## Phase 7: typed Wear handlers - -- [x] Keep listener services limited to callback validation, decoding, and dispatch. -- [x] Put phone command behavior in `PhoneWearCommandHandler` in `app`. -- [x] Put watch command behavior in `WearCommandHandler` in `wear`. -- [x] Keep application preferences, permissions, recording behavior, and navigation out of `WearOsLib`. -- [x] Test handlers with typed commands and local adapters, without Google Play Services callback objects. - -## Final verification - -- [x] `./gradlew testDebugUnitTest detekt` -- [x] `./gradlew :app:assembleDebug :wear:assembleDebug` -- [x] `./gradlew :app:lintDebug :wear:lintDebug` -- [x] `./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin` -- [x] Run the phone sensor recording emulator workflow. -- [ ] Run the Wear-to-phone transfer emulator workflow. -- [x] Update README architecture and test instructions. -- [x] Confirm the final worktree contains no obsolete result, protocol, presentation, or service interfaces. - -Wear workflow note: `tools/emulator/run_wear_sync_test.sh` reached its pairing precondition and exited with the expected guidance because `SensorBox_Wear_API_37` reports no paired phone. APKs and both instrumentation suites compile. Complete the one-time Pair Wearable flow in Android Studio, then rerun the script. diff --git a/docs/recording-architecture-guided-review.md b/docs/recording-architecture-guided-review.md new file mode 100644 index 0000000..655dfd5 --- /dev/null +++ b/docs/recording-architecture-guided-review.md @@ -0,0 +1,142 @@ +# Recording architecture guided review + +This review follows one recording request from the phone UI, through the phone and Wear OS command boundaries, into the Android sensor sources. Read the files in order. At each step, check that the caller expresses a user intent and the dependency owns the implementation detail. + +## 1. Start with the architecture decisions + +1. `adr/0005-define-sensorbox-application-architecture.md` + - Defines module ownership, MVI boundaries, dependency direction, and serialization rules. + - CSV is reserved for sensor sample streams. Other structured data sent or stored by the app uses Kotlin serialization JSON. +2. `adr/0006-prefer-direct-user-intent-execution.md` + - Defines direct `start` and `stop` execution. + - A countdown belongs to the ViewModel and must not prepare folders, services, sources, or Wear commands. +3. `adr/0002-separate-recording-engine-from-android-adapters.md` + - Defines the platform-neutral engine and the Android source adapters around it. + +## 2. Follow the phone UI intent + +Read: + +- `../app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt` + +The root UI sends actions to `RecordingViewModel`. The ViewModel owns the countdown in `startJob`. Until the countdown reaches zero, it does not call the recording use case. The UI only renders `RecordingState.startCountdownSeconds`. + +The useful seams injected into the ViewModel are small and named by intent: + +- `AvailableSensorsUseCase` +- `MeasurementPermissionsUseCase` +- `DocumentStorageGateway` +- `RecordingControlUseCase` +- `SendWearCommandUseCase` + +Check that these interfaces isolate real side effects or a module boundary. There is no general-purpose workflow gateway. + +## 3. Follow phone recording coordination + +Read: + +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementControlUseCase.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/PhoneRecordingController.kt` + +The execution path is: + +```text +RecordingViewModel + -> RecordingControlUseCase + -> PairedRecordingCoordinator + -> PhoneRecordingController + -> SendWearCommandUseCase (only when Wear sources are selected) +``` + +`PairedRecordingCoordinator` owns one small state gate. It starts the phone first, sends a direct Wear start command, and cleans up both sides if Wear fails. It has no prepare/commit/rollback protocol and no mutex. Model-to-command conversions live beside the models in `PairedRecordingModel.kt`. + +Check the stale-command rule: a stop received from Wear only affects the matching active session. + +Also check the disconnected-timer rule. Once the Wear start command has been sent, losing its result does not tear down the phone recording. The phone and Wear services receive the same duration and each local `RecordingEngine` schedules its own stop. Automatic peer notifications are best effort after local cleanup. + +## 4. Review the JSON command boundary + +Read: + +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt` +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt` +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/SendWearCommandUseCase.kt` + +Commands are serializable data classes inside a versioned JSON envelope. Sensor discovery is explicit: phone sends `RequestAvailableSensors`, and Wear replies with `AvailableSensors`. Start and stop are also direct commands. No caller should build an ad-hoc payload. + +## 5. Follow commands received on each device + +Phone side: + +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearCommandHandler.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearRecordingResultInbox.kt` + +Wear side: + +- `../wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt` +- `../wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt` +- `../wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt` +- `../wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt` +- `../wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt` + +Dispatchers decode and route. Handlers state-gate commands and execute them. On Wear, `WearRecordingRequirementsUseCase` isolates checks that are needed at the moment recording starts, while `WearRecordingControlUseCase` isolates starting and stopping the Android service. These are deliberate seams because they wrap separate effects and make command behavior testable. + +## 6. Follow the service into the recording engine + +Read: + +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt` +- `../recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt` +- `../recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt` +- `../recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt` + +`MeasurementService` routes lifecycle and intents. `MeasurementHostResources` owns Android host concerns such as notification, wake lock, receiver, and alarm. The factory assembles a controller. The controller translates the launch request into a direct recording plan. The engine then starts or stops ordered `RecordingSource` instances and knows nothing about Android. + +The key engine seam is `RecordingSource`. It is intentionally small: `start(spec)` and `stop()`. + +## 7. Review Android source construction + +Read: + +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt` + +`AndroidRecordingSources` is only an assembly point. It receives a `Context` long enough to obtain specific platform handles, but does not store the `Context`. Each source adapter lives in its own file and translates one engine source spec into one platform handler. + +## 8. Review storage and transfer boundaries + +Read: + +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt` +- `../core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt` +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt` +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt` +- `../WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt` + +`StorageHandler` is a linear document-storage adapter. Extra measurement metadata, diagnostic entries, Wear file metadata, and command payloads use Kotlin serialization JSON. CSV remains the format for high-volume sensor samples. The Google Play transfer client is a linear open/write/close adapter; it does not own protocol decisions. + +## 9. Finish with behavior tests + +Read these after the production path so each fake has an obvious purpose: + +- `../recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt` +- `../app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt` +- `../wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt` +- `../app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt` +- `../WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt` + +The tests should answer four questions: what user intent entered, which seam was called, what observable state or command resulted, and what cleanup happened after failure. From 76c339a6ca540115a8eb007e9200ed6eb39e57b6 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Thu, 27 Aug 2026 22:09:35 +0200 Subject: [PATCH 2/4] refactor: simplify recording execution across devices --- WearOsLib/build.gradle.kts | 2 + .../GooglePlayWearConnectionRepository.kt | 34 ++- .../connectivity/WearConnectionUseCases.kt | 3 - .../files/GooglePlayWearFileTransferClient.kt | 58 ++-- .../wearoslib/files/WearFileMetadata.kt | 3 + .../wearoslib/files/WearFilePathCodec.kt | 18 +- .../wearoslib/protocol/WearCommand.kt | 73 +++-- .../wearoslib/protocol/WearCommandCodec.kt | 221 ++++---------- .../sensorbox/di/RecordingModule.kt | 33 ++- .../measurement/MeasurementControlUseCase.kt | 17 +- .../MeasurementPermissionUseCase.kt | 9 +- .../domain/measurement/MeasurementRequest.kt | 11 +- .../measurement/PhoneRecordingController.kt | 51 +--- .../measurement/RecordingWorkflowGateway.kt | 53 ---- .../paired/PairedRecordingCoordinator.kt | 279 ++++++------------ .../domain/paired/PairedRecordingModel.kt | 60 ++++ .../paired/PhoneRecordingSessionObserver.kt | 2 +- .../domain/paired/PhoneWearCommandHandler.kt | 44 ++- .../paired/PhoneWearMessageDispatcher.kt | 2 +- .../domain/paired/WearAcknowledgementInbox.kt | 38 --- .../domain/paired/WearRecordingResultInbox.kt | 38 +++ .../sensors/GetAvailableSensorsUseCase.kt | 8 +- .../domain/sensors/SensorDescriptor.kt | 17 ++ .../domain/sensors/WearSensorCatalogStore.kt | 5 + .../presentation/main/RecordingContract.kt | 43 ++- .../presentation/main/RecordingViewModel.kt | 111 ++++--- build.gradle.kts | 1 + core/build.gradle.kts | 2 + .../sensorbox/core/error/FileDiagnostics.kt | 65 ++-- .../sensorbox/recording/RecordingEngine.kt | 216 ++++---------- .../sensorbox/recording/RecordingModel.kt | 14 - .../sensorbox/recording/RecordingSource.kt | 4 +- .../sensorservices/handlers/GPSHandler.kt | 36 +-- .../sensorservices/handlers/StorageHandler.kt | 45 ++- .../handlers/measurements/ExtraInfoHandler.kt | 16 +- .../handlers/measurements/GPSMeasurement.kt | 3 +- .../measurements/SensorMeasurement.kt | 21 +- .../measurements/SignificantMotion.kt | 19 +- .../intent/MeasurementIntentFactory.kt | 1 - .../intent/MeasurementLaunchRequest.kt | 3 - .../ActivityRecordingSource.kt | 34 +++ .../AndroidRecordingSources.kt | 200 ++----------- .../serviceController/GpsRecordingSource.kt | 26 ++ .../serviceController/MeasurementConfig.kt | 8 +- .../RecordingSourceSpecValidation.kt | 10 + .../SensorRecordingSource.kt | 28 ++ .../serviceController/ServiceController.kt | 9 +- .../ServiceControllerFactory.kt | 23 ++ .../serviceController/SessionArtifacts.kt | 52 ++++ .../SessionRecordingSource.kt | 15 + .../SignificantMotionRecordingSource.kt | 24 ++ .../services/MeasurementHostResources.kt | 115 ++++++++ .../services/MeasurementService.kt | 126 ++------ .../PhoneRecordingResultInbox.kt | 38 +++ .../communication/WearAcknowledgementInbox.kt | 38 --- .../communication/WearCommandEnvironment.kt | 28 +- .../communication/WearCommandHandler.kt | 255 ++++++++-------- .../sensorbox/di/RecordingModule.kt | 14 +- .../WearMeasurementControlUseCase.kt | 17 +- .../WearMeasurementPermissionUseCase.kt | 6 +- .../domain/sensors/GetWearSensorsUseCase.kt | 9 + .../domain/sensors/WearSensorDescriptor.kt | 32 +- .../phonelaunch/PhoneLaunchViewModel.kt | 10 +- 63 files changed, 1381 insertions(+), 1415 deletions(-) delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/RecordingWorkflowGateway.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearAcknowledgementInbox.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearRecordingResultInbox.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt create mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt delete mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearAcknowledgementInbox.kt diff --git a/WearOsLib/build.gradle.kts b/WearOsLib/build.gradle.kts index 0d0c30f..bd666e1 100644 --- a/WearOsLib/build.gradle.kts +++ b/WearOsLib/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -47,6 +48,7 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.coroutines.play.services) implementation(libs.hilt.android) + implementation(libs.kotlinx.serialization.json) ksp(libs.hilt.compiler) testImplementation(libs.junit) testFixturesImplementation(project(":core-common")) diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt index f133ee7..68c1a74 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt @@ -2,7 +2,6 @@ package com.tomasrepcik.sensorbox.wearoslib.connectivity import android.content.Context import com.google.android.gms.wearable.CapabilityClient -import com.google.android.gms.wearable.CapabilityInfo import com.google.android.gms.wearable.Node import com.google.android.gms.wearable.Wearable import com.tomasrepcik.sensorbox.core.error.AppError @@ -12,9 +11,13 @@ import com.tomasrepcik.sensorbox.core.error.suspendAppResult import com.tomasrepcik.sensorbox.core.error.suspendFlatMap import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -24,24 +27,35 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext WearConnectionRepository { private val capabilityClient = Wearable.getCapabilityClient(context) private val messageClient = Wearable.getMessageClient(context) + private val nodeClient = Wearable.getNodeClient(context) override fun observeCapability(capability: String): Flow = callbackFlow { - val listener = CapabilityClient.OnCapabilityChangedListener { info -> - trySend(info.toConnection()) + val capabilityListener = CapabilityClient.OnCapabilityChangedListener { + launch { trySend(loadConnection(capability)) } } - capabilityClient.addListener(listener, capability).await() + capabilityClient.addListener(capabilityListener, capability).await() trySend(loadConnection(capability)) - awaitClose { capabilityClient.removeListener(listener) } + val nodePolling = launch { + while (isActive) { + delay(NODE_POLL_INTERVAL_MILLIS) + trySend(loadConnectedNodeConnection()) + } + } + awaitClose { + nodePolling.cancel() + capabilityClient.removeListener(capabilityListener) + } }.catch { error -> AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) emit(WearConnection.Disconnected) - } + }.distinctUntilChanged() override suspend fun findNode(capability: String): WearNode? { val info = capabilityClient .getCapability(capability, CapabilityClient.FILTER_REACHABLE) .await() return WearNodeSelector.select(info.nodes.map { it.toWearNode() }) + ?: WearNodeSelector.select(nodeClient.connectedNodes.await().map { it.toWearNode() }) } override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult = @@ -61,8 +75,8 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext ?.let(WearConnection::Connected) ?: WearConnection.Disconnected - private fun CapabilityInfo.toConnection(): WearConnection = WearNodeSelector - .select(nodes.map { it.toWearNode() }) + private suspend fun loadConnectedNodeConnection(): WearConnection = WearNodeSelector + .select(nodeClient.connectedNodes.await().map { it.toWearNode() }) ?.let(WearConnection::Connected) ?: WearConnection.Disconnected @@ -71,4 +85,8 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext displayName = displayName, isNearby = isNearby, ) + + private companion object { + const val NODE_POLL_INTERVAL_MILLIS = 2_000L + } } diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt index 87d7dff..8f9bbce 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt @@ -9,9 +9,6 @@ class ObserveWearCapabilityUseCase @Inject constructor(private val repository: W } class SendWearMessageUseCase @Inject constructor(private val repository: WearConnectionRepository) { - suspend operator fun invoke(capability: String, path: String, message: String): AppResult = - invoke(capability, path, message.encodeToByteArray()) - suspend operator fun invoke(capability: String, path: String, payload: ByteArray): AppResult = repository.sendMessage(capability, path, payload) } diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt index 570d77b..51d06cb 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt @@ -7,7 +7,6 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap import com.tomasrepcik.sensorbox.core.error.withAppError import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -24,35 +23,52 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c metadata: WearFileMetadata, input: () -> java.io.InputStream, ): AppResult = withContext(Dispatchers.IO) { - WearFilePathCodec.encode(metadata).suspendFlatMap { path -> - suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") { - channelClient.openChannel(nodeId, path).await() - } - }.suspendFlatMap { channel -> - val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") { - input().use { source -> - channelClient.getOutputStream(channel).await().use(source::copyTo) - } - } - val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { - channelClient.close(channel).await() - } - listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Send Wear file") - }.withAppError(AppErrorCode.CONNECTIVITY, "Send Wear file") + val path = when (val encoded = WearFilePathCodec.encode(metadata)) { + is AppResult.Failure -> return@withContext encoded + is AppResult.Success -> encoded.value + } + val channel = when (val opened = openChannel(nodeId, path)) { + is AppResult.Failure -> return@withContext opened + is AppResult.Success -> opened.value + } + val transfer = writeChannel(channel, input) + val close = closeChannel(channel) + listOf(transfer, close) + .combineAppResults(AppErrorCode.CONNECTIVITY, "Send Wear file") + .withAppError(AppErrorCode.CONNECTIVITY, "Send Wear file") } override suspend fun receive( channel: ChannelClient.Channel, consume: (java.io.InputStream) -> AppResult, ): AppResult = withContext(Dispatchers.IO) { - val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") { + val opened = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") { channelClient.getInputStream(channel).await() - }.suspendFlatMap { input -> - input.use(consume) } - val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { - channelClient.close(channel).await() + val transfer = when (opened) { + is AppResult.Failure -> opened + is AppResult.Success -> opened.value.use(consume) } + val close = closeChannel(channel) listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive Wear file") } + + private suspend fun openChannel(nodeId: String, path: String): AppResult = + suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") { + channelClient.openChannel(nodeId, path).await() + } + + private suspend fun writeChannel( + channel: ChannelClient.Channel, + input: () -> java.io.InputStream, + ): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") { + input().use { source -> + channelClient.getOutputStream(channel).await().use(source::copyTo) + } + } + + private suspend fun closeChannel(channel: ChannelClient.Channel): AppResult = + suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { + channelClient.close(channel).await() + } } diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt index 47d50cb..f37880e 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt @@ -1,3 +1,6 @@ package com.tomasrepcik.sensorbox.wearoslib.files +import kotlinx.serialization.Serializable + +@Serializable data class WearFileMetadata(val measurementName: String, val fileName: String) diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt index 06dcc69..d16262a 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt @@ -5,25 +5,29 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.flatMap +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object WearFilePathCodec { fun encode(metadata: WearFileMetadata): AppResult = if ( metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart() ) { appResult(AppErrorCode.CONNECTIVITY, "Encode Wear file path") { - "$PREFIX/${metadata.measurementName.encodePart()}/${metadata.fileName.encodePart()}" + "$PREFIX/${JSON.encodeToString(metadata).encodePart()}" } } else { AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } fun decode(path: String): AppResult { - val parts = path.removePrefix("$PREFIX/").split('/') - if (!path.startsWith("$PREFIX/") || parts.size != 2) { + if (!path.startsWith("$PREFIX/")) { return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } return appResult(AppErrorCode.CONNECTIVITY, "Decode Wear file path") { - WearFileMetadata(parts[0].decodePart(), parts[1].decodePart()) + val encodedMetadata = path.removePrefix("$PREFIX/") + require(encodedMetadata.isNotBlank() && '/' !in encodedMetadata) + JSON.decodeFromString(encodedMetadata.decodePart()) }.flatMap { metadata -> if (metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()) { AppResult.success(metadata) @@ -82,8 +86,12 @@ object WearFilePathCodec { private fun String.isSafePathPart(): Boolean = isNotBlank() && length <= MAX_PART_LENGTH && '/' !in this && '\\' !in this && this != "." && this != ".." - const val PREFIX = "/sensorbox/v1/file" + const val PREFIX = "/sensorbox/v2/file" private const val MAX_PART_LENGTH = 120 private const val BYTE_MASK = 0xFF private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + private val JSON = Json { + encodeDefaults = true + ignoreUnknownKeys = false + } } diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt index 84de4bc..6789ec0 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt @@ -1,33 +1,43 @@ package com.tomasrepcik.sensorbox.wearoslib.protocol import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import kotlinx.serialization.Serializable +@Serializable sealed interface WearCommand { + @Serializable data object LaunchPhone : WearCommand + @Serializable data object SyncMeasurements : WearCommand - data object RequestSensorList : WearCommand + @Serializable + data object RequestAvailableSensors : WearCommand - data class SensorList(val sensors: List) : WearCommand + @Serializable + data class AvailableSensors(val sensors: List) : WearCommand - data class PrepareRecording(val sessionId: String, val request: WearRecordingRequest) : WearCommand - - data class CommitRecording(val sessionId: String, val startAtEpochMillis: Long) : WearCommand - - data class AbortRecording(val sessionId: String) : WearCommand + @Serializable + data class StartRecording(val sessionId: String, val request: WearRecordingRequest) : WearCommand + @Serializable data class StopRecording(val sessionId: String, val reason: WearStopReason) : WearCommand - data class Acknowledgement( + @Serializable + data class RecordingResult( val sessionId: String, - val command: WearSessionCommand, - val outcome: WearAcknowledgementOutcome, + val action: WearRecordingAction, + val outcome: WearRecordingOutcome, + @Serializable(with = AppErrorCodeNameSerializer::class) val errorCode: AppErrorCode? = null, + val errorOperation: String? = null, + val errorMessage: String? = null, + val errorContext: Map = emptyMap(), val failureCount: Int = 0, ) : WearCommand } +@Serializable data class WearRecordingRequest( val folderName: String, val sensorIds: List, @@ -35,26 +45,53 @@ data class WearRecordingRequest( val durationMillis: Long = 0L, ) -enum class WearSessionCommand { - PREPARE, - COMMIT, - ABORT, +@Serializable +enum class WearRecordingAction { + START, STOP, } -enum class WearAcknowledgementOutcome { +@Serializable +enum class WearRecordingOutcome { SUCCEEDED, - REJECTED, FAILED, } +@Serializable enum class WearStopReason { USER_REQUEST, DURATION_EXPIRED, LOW_BATTERY, SOURCE_FAILURE, - PAIRED_ABORT, SERVICE_DESTROYED, } -data class WearSensorInfo(val type: Int, val name: String, val vendor: String) +@Serializable +data class WearSensorInfo( + val type: Int, + val name: String, + val vendor: String, + val version: Int, + val stringType: String, + val maximumRange: Float, + val resolution: Float, + val power: Float, + val minimumDelayMicros: Int, + val maximumDelayMicros: Int, + val reportingMode: Int, + val isWakeUpSensor: Boolean, +) + +fun WearCommand.recordingSessionId(): String? = when (this) { + is WearCommand.StartRecording -> sessionId + + is WearCommand.StopRecording -> sessionId + + is WearCommand.RecordingResult -> sessionId + + WearCommand.LaunchPhone, + WearCommand.RequestAvailableSensors, + is WearCommand.AvailableSensors, + WearCommand.SyncMeasurements, + -> null +} diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt index 2c55ba8..5a23440 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt @@ -5,214 +5,123 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.flatMap -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json object WearCommandCodec { - const val PROTOCOL_VERSION = 3 + const val PROTOCOL_VERSION = 5 + + private val json = Json { + classDiscriminator = "commandType" + encodeDefaults = true + ignoreUnknownKeys = false + } fun encode(command: WearCommand): AppResult = validate(command).flatMap { appResult(AppErrorCode.CONNECTIVITY, "Encode Wear command") { - ByteArrayOutputStream().use { bytes -> - DataOutputStream(bytes).use { output -> - output.writeInt(MAGIC) - output.writeByte(PROTOCOL_VERSION) - output.writeCommand(command) - } - bytes.toByteArray() - } + json.encodeToString(WearCommandEnvelope(PROTOCOL_VERSION, command)).encodeToByteArray() } } fun decode(payload: ByteArray): AppResult = appResult( AppErrorCode.CONNECTIVITY, - "Decode Wear protocol v3 command", + "Decode Wear protocol v$PROTOCOL_VERSION command", ) { - DataInputStream(ByteArrayInputStream(payload)).use { input -> - require(input.readInt() == MAGIC) { "Unsupported Wear protocol magic" } - require(input.readUnsignedByte() == PROTOCOL_VERSION) { "Unsupported Wear protocol version" } - input.readCommand().also { require(input.available() == 0) { "Trailing Wear command bytes" } } - } + val envelope = json.decodeFromString(payload.decodeToString()) + require(envelope.protocolVersion == PROTOCOL_VERSION) { "Unsupported Wear protocol version" } + envelope.command }.flatMap { command -> validate(command).map { command } } private fun validate(command: WearCommand): AppResult = if (command.isValid()) { AppResult.success(Unit) } else { - AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v3 command")) - } - - private fun DataOutputStream.writeCommand(command: WearCommand) { - when (command) { - WearCommand.LaunchPhone -> writeByte(TYPE_LAUNCH_PHONE) - - WearCommand.SyncMeasurements -> writeByte(TYPE_SYNC_MEASUREMENTS) - - WearCommand.RequestSensorList -> writeByte(TYPE_REQUEST_SENSOR_LIST) - - is WearCommand.SensorList -> writeSensorList(command) - - is WearCommand.PrepareRecording -> writePrepare(command) - - is WearCommand.CommitRecording -> { - writeByte(TYPE_COMMIT_RECORDING) - writeUTF(command.sessionId) - writeLong(command.startAtEpochMillis) - } - - is WearCommand.AbortRecording -> { - writeByte(TYPE_ABORT_RECORDING) - writeUTF(command.sessionId) - } - - is WearCommand.StopRecording -> { - writeByte(TYPE_STOP_RECORDING) - writeUTF(command.sessionId) - writeUTF(command.reason.name) - } - - is WearCommand.Acknowledgement -> writeAcknowledgement(command) - } - } - - private fun DataOutputStream.writePrepare(command: WearCommand.PrepareRecording) { - writeByte(TYPE_PREPARE_RECORDING) - writeUTF(command.sessionId) - writeUTF(command.request.folderName) - writeBoolean(command.request.includesGps) - writeByte(command.request.sensorIds.size) - command.request.sensorIds.forEach(::writeInt) - writeLong(command.request.durationMillis) - } - - private fun DataOutputStream.writeAcknowledgement(command: WearCommand.Acknowledgement) { - writeByte(TYPE_ACKNOWLEDGEMENT) - writeUTF(command.sessionId) - writeUTF(command.command.name) - writeUTF(command.outcome.name) - writeBoolean(command.errorCode != null) - command.errorCode?.let { writeUTF(it.name) } - writeInt(command.failureCount) - } - - private fun DataOutputStream.writeSensorList(command: WearCommand.SensorList) { - writeByte(TYPE_SENSOR_LIST) - writeByte(command.sensors.size) - command.sensors.forEach { sensor -> - writeInt(sensor.type) - writeUTF(sensor.name) - writeUTF(sensor.vendor) - } + AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v$PROTOCOL_VERSION command")) } +} - private fun DataInputStream.readCommand(): WearCommand = when (readUnsignedByte()) { - TYPE_LAUNCH_PHONE -> WearCommand.LaunchPhone - TYPE_SYNC_MEASUREMENTS -> WearCommand.SyncMeasurements - TYPE_REQUEST_SENSOR_LIST -> WearCommand.RequestSensorList - TYPE_SENSOR_LIST -> readSensorList() - TYPE_PREPARE_RECORDING -> readPrepare() - TYPE_COMMIT_RECORDING -> WearCommand.CommitRecording(readUTF(), readLong()) - TYPE_ABORT_RECORDING -> WearCommand.AbortRecording(readUTF()) - TYPE_STOP_RECORDING -> WearCommand.StopRecording(readUTF(), readNamedEnum(WearStopReason.entries)) - TYPE_ACKNOWLEDGEMENT -> readAcknowledgement() - else -> error("Unknown Wear command type") - } +@Serializable +private data class WearCommandEnvelope(val protocolVersion: Int, val command: WearCommand) - private fun DataInputStream.readPrepare(): WearCommand.PrepareRecording { - val sessionId = readUTF() - val folderName = readUTF() - val includesGps = readBoolean() - val sensorCount = readUnsignedByte() - require(sensorCount <= MAX_SENSORS) { "Too many Wear sensors" } - val sensorIds = List(sensorCount) { readInt() } - return WearCommand.PrepareRecording( - sessionId = sessionId, - request = WearRecordingRequest( - folderName = folderName, - sensorIds = sensorIds, - includesGps = includesGps, - durationMillis = readLong(), - ), - ) - } +internal object AppErrorCodeNameSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("AppErrorCode", PrimitiveKind.STRING) - private fun DataInputStream.readAcknowledgement(): WearCommand.Acknowledgement { - val sessionId = readUTF() - val command = readNamedEnum(WearSessionCommand.entries) - val outcome = readNamedEnum(WearAcknowledgementOutcome.entries) - val errorCode = if (readBoolean()) readNamedEnum(AppErrorCode.entries) else null - return WearCommand.Acknowledgement(sessionId, command, outcome, errorCode, readInt()) + override fun serialize(encoder: Encoder, value: AppErrorCode) { + encoder.encodeString(value.name) } - private fun DataInputStream.readSensorList(): WearCommand.SensorList { - val count = readUnsignedByte() - require(count <= MAX_SENSORS) { "Too many Wear sensors" } - return WearCommand.SensorList( - List(count) { WearSensorInfo(readInt(), readUTF(), readUTF()) }, - ) + override fun deserialize(decoder: Decoder): AppErrorCode { + val name = decoder.decodeString() + return AppErrorCode.entries.firstOrNull { it.name == name } + ?: error("Unknown application error code") } - - private const val MAGIC = 0x53425833 - private const val TYPE_LAUNCH_PHONE = 1 - private const val TYPE_SYNC_MEASUREMENTS = 2 - private const val TYPE_REQUEST_SENSOR_LIST = 3 - private const val TYPE_SENSOR_LIST = 4 - private const val TYPE_PREPARE_RECORDING = 10 - private const val TYPE_COMMIT_RECORDING = 11 - private const val TYPE_ABORT_RECORDING = 12 - private const val TYPE_STOP_RECORDING = 13 - private const val TYPE_ACKNOWLEDGEMENT = 14 } private const val MAX_SENSORS = 64 private const val MAX_FOLDER_LENGTH = 100 private const val MAX_SENSOR_TEXT_LENGTH = 100 private const val MAX_SESSION_ID_LENGTH = 128 +private const val MAX_ERROR_TEXT_LENGTH = 500 +private const val MAX_ERROR_CONTEXT_ENTRIES = 16 private fun WearCommand.isValid(): Boolean = when (this) { WearCommand.LaunchPhone, - WearCommand.RequestSensorList, + WearCommand.RequestAvailableSensors, WearCommand.SyncMeasurements, -> true - is WearCommand.SensorList -> sensors.size <= MAX_SENSORS && sensors.all(WearSensorInfo::isValid) - - is WearCommand.PrepareRecording -> isValid() + is WearCommand.AvailableSensors -> sensors.size <= MAX_SENSORS && sensors.all(WearSensorInfo::isValid) - is WearCommand.CommitRecording -> validSessionId(sessionId) && startAtEpochMillis >= 0L - - is WearCommand.AbortRecording -> validSessionId(sessionId) + is WearCommand.StartRecording -> isValid() is WearCommand.StopRecording -> validSessionId(sessionId) - is WearCommand.Acknowledgement -> isValid() + is WearCommand.RecordingResult -> isValid() } -private fun WearSensorInfo.isValid(): Boolean = - name.length <= MAX_SENSOR_TEXT_LENGTH && vendor.length <= MAX_SENSOR_TEXT_LENGTH +private fun WearSensorInfo.isValid(): Boolean = name.length <= MAX_SENSOR_TEXT_LENGTH && + vendor.length <= MAX_SENSOR_TEXT_LENGTH && + stringType.length <= MAX_SENSOR_TEXT_LENGTH && + maximumRange.isFinite() && + resolution.isFinite() && + power.isFinite() -private fun WearCommand.PrepareRecording.isValid(): Boolean = validSessionId(sessionId) && +private fun WearCommand.StartRecording.isValid(): Boolean = validSessionId(sessionId) && request.folderName.isNotBlank() && request.folderName.length <= MAX_FOLDER_LENGTH && request.sensorIds.size <= MAX_SENSORS && request.durationMillis >= 0L -private fun WearCommand.Acknowledgement.isValid(): Boolean = validSessionId(sessionId) && +private fun WearCommand.RecordingResult.isValid(): Boolean = validSessionId(sessionId) && failureCount >= 0 && + hasValidErrorContext() && when (outcome) { - WearAcknowledgementOutcome.SUCCEEDED -> errorCode == null && failureCount == 0 + WearRecordingOutcome.SUCCEEDED -> hasNoError() + WearRecordingOutcome.FAILED -> hasValidError() + } - WearAcknowledgementOutcome.REJECTED, - WearAcknowledgementOutcome.FAILED, - -> errorCode != null +private fun WearCommand.RecordingResult.hasValidErrorContext(): Boolean = + errorContext.size <= MAX_ERROR_CONTEXT_ENTRIES && errorContext.all { (key, value) -> + key.isNotBlank() && key.length <= MAX_ERROR_TEXT_LENGTH && value.length <= MAX_ERROR_TEXT_LENGTH } +private fun WearCommand.RecordingResult.hasNoError(): Boolean = errorCode == null && + errorOperation == null && + errorMessage == null && + errorContext.isEmpty() && + failureCount == 0 + +private fun WearCommand.RecordingResult.hasValidError(): Boolean = errorCode != null && + !errorOperation.isNullOrBlank() && + errorOperation.length <= MAX_ERROR_TEXT_LENGTH && + !errorMessage.isNullOrBlank() && + errorMessage.length <= MAX_ERROR_TEXT_LENGTH + private fun validSessionId(sessionId: String): Boolean = sessionId.isNotBlank() && sessionId.length <= MAX_SESSION_ID_LENGTH && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' } - -private inline fun > DataInputStream.readNamedEnum(values: List): T { - val name = readUTF() - return values.firstOrNull { it.name == name } ?: error("Unknown Wear protocol enum value") -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt index 2d76357..6b5aee2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt @@ -1,13 +1,19 @@ package com.tomasrepcik.sensorbox.di +import com.tomasrepcik.sensorbox.data.measurements.AndroidMeasurementRepository import com.tomasrepcik.sensorbox.domain.measurement.AndroidPhoneRecordingController -import com.tomasrepcik.sensorbox.domain.measurement.AndroidRecordingWorkflowGateway import com.tomasrepcik.sensorbox.domain.measurement.DocumentStorageGateway import com.tomasrepcik.sensorbox.domain.measurement.DocumentStorageUseCase +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementControlUseCase +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementPermissionUseCase +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementPermissionsUseCase import com.tomasrepcik.sensorbox.domain.measurement.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.measurement.RecordingWorkflowGateway -import com.tomasrepcik.sensorbox.domain.paired.PhoneWearCommandHandler -import com.tomasrepcik.sensorbox.domain.paired.PhoneWearCommandPolicy +import com.tomasrepcik.sensorbox.domain.measurement.RecordingControlUseCase +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository +import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.GetAvailableSensorsUseCase +import com.tomasrepcik.sensorbox.presentation.main.AndroidElapsedRealtimeClock +import com.tomasrepcik.sensorbox.presentation.main.ElapsedRealtimeClock import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -22,13 +28,22 @@ abstract class RecordingModule { ): PhoneRecordingController @Binds - abstract fun bindRecordingWorkflowGateway( - implementation: AndroidRecordingWorkflowGateway, - ): RecordingWorkflowGateway + abstract fun bindDocumentStorageGateway(implementation: DocumentStorageUseCase): DocumentStorageGateway @Binds - abstract fun bindDocumentStorageGateway(implementation: DocumentStorageUseCase): DocumentStorageGateway + abstract fun bindAvailableSensorsUseCase(implementation: GetAvailableSensorsUseCase): AvailableSensorsUseCase + + @Binds + abstract fun bindMeasurementPermissionsUseCase( + implementation: MeasurementPermissionUseCase, + ): MeasurementPermissionsUseCase + + @Binds + abstract fun bindRecordingControlUseCase(implementation: MeasurementControlUseCase): RecordingControlUseCase + + @Binds + abstract fun bindMeasurementRepository(implementation: AndroidMeasurementRepository): MeasurementRepository @Binds - abstract fun bindPhoneWearCommandPolicy(implementation: PhoneWearCommandHandler): PhoneWearCommandPolicy + abstract fun bindElapsedRealtimeClock(implementation: AndroidElapsedRealtimeClock): ElapsedRealtimeClock } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementControlUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementControlUseCase.kt index 2484cc8..932044a 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementControlUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementControlUseCase.kt @@ -6,15 +6,22 @@ import com.tomasrepcik.sensorbox.domain.paired.PairedRecordingCoordinator import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import javax.inject.Inject +interface RecordingControlUseCase { + suspend fun start(request: MeasurementRequest): AppResult + + suspend fun stop(): AppResult + + fun annotate(text: String): AppResult +} + class MeasurementControlUseCase @Inject constructor( private val pairedRecordingCoordinator: PairedRecordingCoordinator, private val localController: PhoneRecordingController, private val clock: EpochClock, -) { - suspend fun start(request: MeasurementRequest): AppResult = pairedRecordingCoordinator.start(request) +) : RecordingControlUseCase { + override suspend fun start(request: MeasurementRequest): AppResult = pairedRecordingCoordinator.start(request) - suspend fun stop(): AppResult = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST) + override suspend fun stop(): AppResult = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST) - fun annotate(text: String, timestampMillis: Long = clock.nowMillis()): AppResult = - localController.annotate(text, timestampMillis) + override fun annotate(text: String): AppResult = localController.annotate(text, clock.nowMillis()) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt index 4ec925d..f14ec46 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementPermissionUseCase.kt @@ -8,8 +8,13 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class MeasurementPermissionUseCase @Inject constructor(@ApplicationContext private val context: Context) { - fun missingPermissions(request: MeasurementRequest): Set = +fun interface MeasurementPermissionsUseCase { + fun missingPermissions(request: MeasurementRequest): Set +} + +class MeasurementPermissionUseCase @Inject constructor(@ApplicationContext private val context: Context) : + MeasurementPermissionsUseCase { + override fun missingPermissions(request: MeasurementRequest): Set = requiredMeasurementPermissions(request, Build.VERSION.SDK_INT).filterNot(::isGranted).toSet() private fun isGranted(permission: String): Boolean = diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementRequest.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementRequest.kt index 0f96f7e..56de7e9 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementRequest.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/MeasurementRequest.kt @@ -2,6 +2,7 @@ package com.tomasrepcik.sensorbox.domain.measurement import android.hardware.SensorManager import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest data class MeasurementRequest( val sensorIds: Set, @@ -14,7 +15,6 @@ data class MeasurementRequest( val wearSensorIds: Set = emptySet(), val wearIncludesGps: Boolean = false, val customName: String = "", - val delaySeconds: Int = 0, val durationSeconds: Int = 0, val notes: List = emptyList(), val alarmOffsetsSeconds: List = emptyList(), @@ -52,3 +52,12 @@ data class MeasurementRequest( ) } } + +data class StartedPhoneRecording(val sessionId: String, val folderName: String, val durationMillis: Long) + +fun StartedPhoneRecording.toWearRecordingRequest(request: MeasurementRequest) = WearRecordingRequest( + folderName = folderName, + sensorIds = request.wearSensorIds.sorted(), + includesGps = request.wearIncludesGps, + durationMillis = durationMillis, +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/PhoneRecordingController.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/PhoneRecordingController.kt index 77d9303..904f109 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/PhoneRecordingController.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/PhoneRecordingController.kt @@ -10,25 +10,18 @@ import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton -data class PreparedPhoneRecording(val sessionId: String, val launchRequest: MeasurementLaunchRequest) - interface PhoneRecordingController { - suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult - - fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult - - fun abort(sessionId: String): AppResult + fun start(sessionId: String, request: MeasurementRequest): AppResult fun stop(sessionId: String, reason: WearStopReason): AppResult - fun stopAny(reason: WearStopReason): AppResult + fun stopCurrent(reason: WearStopReason): AppResult fun annotate(text: String, timestampMillis: Long): AppResult } @@ -39,47 +32,33 @@ class AndroidPhoneRecordingController @Inject constructor( private val intentFactory: MeasurementIntentFactory, private val documentStorage: DocumentStorage, ) : PhoneRecordingController { - private val committedSessions = mutableSetOf() - - override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = + override fun start(sessionId: String, request: MeasurementRequest): AppResult = documentStorage .hasConfiguredDirectory() .flatMap { storageReady -> if (!storageReady) { - AppResult.failure(AppError(AppErrorCode.STORAGE, "Prepare phone recording storage")) + AppResult.failure(AppError(AppErrorCode.STORAGE, "Start phone recording storage")) } else { - AppResult.success( - PreparedPhoneRecording( - sessionId = sessionId, - launchRequest = request.toLaunchRequest( - sessionId, - intentFactory.newFolderName(request.customName), - ), - ), - ) + startForegroundRecording(sessionId, request) } } - override fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult = appResult( + private fun startForegroundRecording( + sessionId: String, + request: MeasurementRequest, + ): AppResult = appResult( AppErrorCode.MEASUREMENT, - "Commit phone recording", + "Start phone recording", ) { - val launchRequest = prepared.launchRequest.copy(startAtEpochMillis = startAtEpochMillis) + val folderName = intentFactory.newFolderName(request.customName) + val launchRequest = request.toLaunchRequest(sessionId, folderName) ContextCompat.startForegroundService(context, intentFactory.create(launchRequest)) - synchronized(committedSessions) { committedSessions += prepared.sessionId } + StartedPhoneRecording(sessionId, folderName, launchRequest.durationMillis) } - override fun abort(sessionId: String): AppResult { - val wasCommitted = synchronized(committedSessions) { committedSessions.remove(sessionId) } - return if (wasCommitted) stopService("Abort phone recording") else AppResult.success(Unit) - } - - override fun stop(sessionId: String, reason: WearStopReason): AppResult { - synchronized(committedSessions) { committedSessions.remove(sessionId) } - return stopService("Stop phone recording") - } + override fun stop(sessionId: String, reason: WearStopReason): AppResult = stopService("Stop phone recording") - override fun stopAny(reason: WearStopReason): AppResult = stopService("Stop phone recording") + override fun stopCurrent(reason: WearStopReason): AppResult = stopService("Stop current phone recording") override fun annotate(text: String, timestampMillis: Long): AppResult = appResult( AppErrorCode.MEASUREMENT, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/RecordingWorkflowGateway.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/RecordingWorkflowGateway.kt deleted file mode 100644 index 0f24914..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/RecordingWorkflowGateway.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.measurement - -import android.content.Intent -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.domain.sensors.GetAvailableSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import javax.inject.Inject - -interface RecordingWorkflowGateway { - fun sensors(): List - - fun storagePath(): String? - - fun hasStorage(): Boolean - - fun persistStorage(resultIntent: Intent?): AppResult - - fun missingPermissions(request: MeasurementRequest): Set - - suspend fun start(request: MeasurementRequest): AppResult - - suspend fun stop(): AppResult - - fun annotate(text: String): AppResult -} - -class AndroidRecordingWorkflowGateway @Inject constructor( - private val getAvailableSensors: GetAvailableSensorsUseCase, - private val documentStorage: DocumentStorageUseCase, - private val measurementPermissions: MeasurementPermissionUseCase, - private val measurementControl: MeasurementControlUseCase, -) : RecordingWorkflowGateway { - override fun sensors(): List = getAvailableSensors() - - override fun storagePath(): String? = documentStorage.displayPath().getOrNull() - - override fun hasStorage(): Boolean = documentStorage.hasStorage().getOrNull() == true - - override fun persistStorage(resultIntent: Intent?): AppResult = resultIntent - ?.let(documentStorage::persist) - ?: AppResult.failure(AppError(AppErrorCode.STORAGE, "Select recording storage directory")) - - override fun missingPermissions(request: MeasurementRequest): Set = - measurementPermissions.missingPermissions(request) - - override suspend fun start(request: MeasurementRequest): AppResult = measurementControl.start(request) - - override suspend fun stop(): AppResult = measurementControl.stop() - - override fun annotate(text: String): AppResult = measurementControl.annotate(text) -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt index 456e44c..e502643 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt @@ -6,261 +6,168 @@ import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.domain.measurement.MeasurementRequest import com.tomasrepcik.sensorbox.domain.measurement.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.measurement.PreparedPhoneRecording import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearAcknowledgementOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import com.tomasrepcik.sensorbox.wearoslib.protocol.recordingSessionId import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -data class PairedRecordingSession(val sessionId: String, val controlsWear: Boolean) - -class RecordingSessionIdFactory @Inject constructor() { - fun create(): String = UUID.randomUUID().toString() -} - @Singleton class PairedRecordingCoordinator @Inject constructor( - private val localController: PhoneRecordingController, + private val phoneRecording: PhoneRecordingController, private val sendCommand: SendWearCommandUseCase, - private val acknowledgementInbox: WearAcknowledgementInbox, - private val sessionIdFactory: RecordingSessionIdFactory, + private val watchResults: WearRecordingResultInbox, private val diagnosticLogger: DiagnosticLogger, - private val clock: EpochClock, ) { - private val mutex = Mutex() - private val mutableSession = MutableStateFlow(null) - - val session: StateFlow = mutableSession.asStateFlow() + private var state: PairedRecordingState = PairedRecordingState.Idle - suspend fun start(request: MeasurementRequest): AppResult = mutex.withLock { - if (mutableSession.value != null) return@withLock conflict("Start recording") - val sessionId = sessionIdFactory.create() - when (val preparation = prepareBoth(sessionId, request)) { - is AppResult.Failure -> record(preparation) + @Suppress("ReturnCount") + suspend fun start(request: MeasurementRequest): AppResult { + if (state !is PairedRecordingState.Idle) return conflict("Start recording") - is AppResult.Success -> when (val commit = commitBoth(preparation.value, request)) { - is AppResult.Failure -> record(commit) + val sessionId = UUID.randomUUID().toString() + val includesWatch = request.includesWatchRecording() + val starting = PairedRecordingState.Starting(sessionId, includesWatch) + state = starting - is AppResult.Success -> { - mutableSession.value = PairedRecordingSession(sessionId, preparation.value.controlsWear) - AppResult.success(Unit) - } + val startedPhone = when (val phoneStart = phoneRecording.start(sessionId, request)) { + is AppResult.Failure -> { + state = PairedRecordingState.Idle + return record(phoneStart) } - } - } - private suspend fun prepareBoth(sessionId: String, request: MeasurementRequest): AppResult = - when (val local = localController.prepare(sessionId, request)) { - is AppResult.Failure -> local - is AppResult.Success -> prepareWearIfNeeded(sessionId, request, local.value) + is AppResult.Success -> phoneStart.value } - private suspend fun prepareWearIfNeeded( - sessionId: String, - request: MeasurementRequest, - prepared: PreparedPhoneRecording, - ): AppResult { - val controlsWear = request.wearSensorIds.isNotEmpty() || request.wearIncludesGps - if (!controlsWear) return AppResult.success(PairedPreparation(prepared, controlsWear = false)) - val result = exchange( - command = WearCommand.PrepareRecording(sessionId, prepared.toWearRequest(request)), - expected = WearSessionCommand.PREPARE, - timeoutMillis = PREPARE_TIMEOUT_MILLIS, - ) - return when (result) { - is AppResult.Success -> AppResult.success(PairedPreparation(prepared, controlsWear = true)) + val watchStart = if (includesWatch) { + exchange(startedPhone.toWatchStartCommand(request), WearRecordingAction.START) + } else { + AppResult.success(Unit) + } - is AppResult.Failure -> { - compensate(sessionId, prepared, localWasCommitted = false) - result + if (state !== starting) return AppResult.success(Unit) + if (watchStart is AppResult.Failure) { + if (watchStart.error.wasWearCommandSent()) { + record(watchStart.error) + state = PairedRecordingState.Recording(sessionId, includesWatch) + return AppResult.success(Unit) } - } - } - private suspend fun commitBoth(preparation: PairedPreparation, request: MeasurementRequest): AppResult { - val sessionId = preparation.prepared.sessionId - val startAtEpochMillis = clock.nowMillis() + request.delaySeconds.coerceAtLeast(0) * 1_000L + - if (preparation.controlsWear) PAIRED_START_LEAD_MILLIS else 0L - val local = localController.commit(preparation.prepared, startAtEpochMillis) - if (local is AppResult.Failure) { - if (preparation.controlsWear) compensate(sessionId, preparation.prepared, localWasCommitted = false) - return local + stopStartedRecording(sessionId, includesWatch, WearStopReason.SOURCE_FAILURE) + return record(watchStart) } - if (!preparation.controlsWear) return AppResult.success(Unit) - val remote = exchange( - command = WearCommand.CommitRecording(sessionId, startAtEpochMillis), - expected = WearSessionCommand.COMMIT, - timeoutMillis = COMMIT_TIMEOUT_MILLIS, - ) - if (remote is AppResult.Failure) { - compensate(sessionId, preparation.prepared, localWasCommitted = true) - } - return remote + + state = PairedRecordingState.Recording(sessionId, includesWatch) + return AppResult.success(Unit) } - suspend fun stop(reason: WearStopReason = WearStopReason.USER_REQUEST): AppResult = mutex.withLock { - val active = mutableSession.value - ?: return@withLock localController.stopAny(reason).onFailure { record(it) } - val localStop = localController.stop(active.sessionId, reason) - val remoteStop = if (active.controlsWear) { - exchange( - command = WearCommand.StopRecording(active.sessionId, reason), - expected = WearSessionCommand.STOP, - timeoutMillis = COMMIT_TIMEOUT_MILLIS, - ) - } else { - AppResult.success(Unit) - } - mutableSession.value = null - listOf(localStop, remoteStop) - .combineAppResults(AppErrorCode.MEASUREMENT, "Stop paired recording") - .onFailure { record(it) } + suspend fun stop(reason: WearStopReason = WearStopReason.USER_REQUEST): AppResult { + val active = state.activeRecording() + ?: return phoneRecording.stopCurrent(reason).onFailure(::record) + state = PairedRecordingState.Stopping(active.sessionId, active.includesWatch) + return stopStartedRecording(active.sessionId, active.includesWatch, reason) } - suspend fun onAutomaticLocalStop(sessionId: String, reason: WearStopReason): AppResult = mutex.withLock { - val active = mutableSession.value - if (active?.sessionId != sessionId) return@withLock AppResult.success(Unit) - val remoteStop = if (active.controlsWear) { - exchange( - command = WearCommand.StopRecording(active.sessionId, reason), - expected = WearSessionCommand.STOP, - timeoutMillis = COMMIT_TIMEOUT_MILLIS, - ) - } else { - AppResult.success(Unit) - } - mutableSession.value = null - remoteStop.onFailure { record(it) } + suspend fun onAutomaticPhoneStop(sessionId: String, reason: WearStopReason): AppResult { + val active = state.activeRecording() + if (active?.sessionId != sessionId) return AppResult.success(Unit) + + val watchStop = stopWatch(sessionId, active.includesWatch, reason) + state = PairedRecordingState.Idle + return watchStop.onFailure(::record) } - suspend fun stopFromPeer(sessionId: String, reason: WearStopReason): AppResult = mutex.withLock { - val result = localController.stop(sessionId, reason) - if (mutableSession.value?.sessionId == sessionId) mutableSession.value = null - result.onFailure { record(it) } + suspend fun stopFromWatch(sessionId: String, reason: WearStopReason): AppResult { + if (state.activeRecording()?.sessionId != sessionId) return AppResult.success(Unit) + + val result = phoneRecording.stop(sessionId, reason) + state = PairedRecordingState.Idle + return result.onFailure(::record) } - private suspend fun exchange( - command: WearCommand, - expected: WearSessionCommand, - timeoutMillis: Long, + private suspend fun stopStartedRecording( + sessionId: String, + includesWatch: Boolean, + reason: WearStopReason, ): AppResult { - val sessionId = command.sessionId() - acknowledgementInbox.clear(sessionId, expected) + val phoneStop = phoneRecording.stop(sessionId, reason) + val watchStop = stopWatch(sessionId, includesWatch, reason) + state = PairedRecordingState.Idle + return listOf(phoneStop, watchStop) + .combineAppResults(AppErrorCode.MEASUREMENT, "Stop phone and Wear recording") + .onFailure(::record) + } + + private suspend fun stopWatch(sessionId: String, includesWatch: Boolean, reason: WearStopReason): AppResult = + if (includesWatch) { + exchange(WearCommand.StopRecording(sessionId, reason), WearRecordingAction.STOP) + } else { + AppResult.success(Unit) + } + + private suspend fun exchange(command: WearCommand, expectedAction: WearRecordingAction): AppResult { + val sessionId = checkNotNull(command.recordingSessionId()) + watchResults.clear(sessionId, expectedAction) var lastSendError: AppError? = null + var commandWasSent = false + repeat(ATTEMPT_COUNT) { retryCount -> when (val sent = sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command)) { is AppResult.Failure -> lastSendError = sent.error is AppResult.Success -> { + commandWasSent = true lastSendError = null - val acknowledgement = withTimeoutOrNull(timeoutMillis / ATTEMPT_COUNT) { - acknowledgementInbox.await(sessionId, expected) + val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) { + watchResults.await(sessionId, expectedAction) } - if (acknowledgement != null) return acknowledgement.toResult(retryCount) + if (result != null) return result.toAppResult(retryCount) } } } - return lastSendError?.let { error -> AppResult.failure(error) } ?: AppResult.failure( + + if (!commandWasSent && lastSendError != null) return AppResult.failure(checkNotNull(lastSendError)) + + return AppResult.failure( AppError( code = AppErrorCode.TIMEOUT, - operation = "Await Wear $expected acknowledgement", - diagnosticMessage = "Wear $expected acknowledgement timed out", + operation = expectedAction.timeoutOperation(), + diagnosticMessage = "Wear $expectedAction result timed out", context = mapOf( + "source" to "wear", "sessionId" to sessionId, "retryCount" to RETRY_COUNT.toString(), "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), + "commandWasSent" to commandWasSent.toString(), ), isRetryable = true, ), ) } - private suspend fun compensate(sessionId: String, prepared: PreparedPhoneRecording, localWasCommitted: Boolean) { - if (localWasCommitted) localController.abort(prepared.sessionId) else localController.abort(sessionId) - exchange( - command = WearCommand.AbortRecording(sessionId), - expected = WearSessionCommand.ABORT, - timeoutMillis = COMMIT_TIMEOUT_MILLIS, - ) - } + private fun AppError.wasWearCommandSent(): Boolean = + code == AppErrorCode.TIMEOUT && context["commandWasSent"] == true.toString() - private fun PreparedPhoneRecording.toWearRequest(request: MeasurementRequest) = WearRecordingRequest( - folderName = launchRequest.folderName, - sensorIds = request.wearSensorIds.sorted(), - includesGps = request.wearIncludesGps, - durationMillis = launchRequest.durationMillis, - ) + private fun conflict(operation: String): AppResult = + AppResult.failure(AppError(AppErrorCode.CONFLICT, operation)) - private fun WearCommand.Acknowledgement.toResult(retryCount: Int): AppResult = - if (outcome == WearAcknowledgementOutcome.SUCCEEDED) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError( - code = errorCode ?: AppErrorCode.UNKNOWN, - operation = "Handle Wear $command acknowledgement", - diagnosticMessage = "Wear $command acknowledgement was $outcome", - context = mapOf( - "sessionId" to sessionId, - "retryCount" to retryCount.toString(), - "failureCount" to failureCount.toString(), - "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), - ), - ), - ) - } - - private fun WearCommand.sessionId(): String = when (this) { - is WearCommand.PrepareRecording -> sessionId - - is WearCommand.CommitRecording -> sessionId - - is WearCommand.AbortRecording -> sessionId - - is WearCommand.StopRecording -> sessionId - - is WearCommand.Acknowledgement -> sessionId - - WearCommand.LaunchPhone, - WearCommand.SyncMeasurements, - WearCommand.RequestSensorList, - is WearCommand.SensorList, - -> error("Wear command has no recording session") - } - - private fun conflict(operation: String): AppResult = AppResult.failure( - AppError(AppErrorCode.CONFLICT, operation), - ) - - private fun record(result: AppResult): AppResult = result.onFailure { record(it) } + private fun record(result: AppResult): AppResult = result.onFailure(::record) private fun record(error: AppError) { diagnosticLogger.record(error.toDiagnosticEvent()) } - private data class PairedPreparation(val prepared: PreparedPhoneRecording, val controlsWear: Boolean) - private companion object { - const val PREPARE_TIMEOUT_MILLIS = 10_000L - const val COMMIT_TIMEOUT_MILLIS = 5_000L - const val PAIRED_START_LEAD_MILLIS = 6_000L + const val RESULT_TIMEOUT_MILLIS = 5_000L const val RETRY_COUNT = 2 const val ATTEMPT_COUNT = RETRY_COUNT + 1 } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt new file mode 100644 index 0000000..c5514a4 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt @@ -0,0 +1,60 @@ +package com.tomasrepcik.sensorbox.domain.paired + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementRequest +import com.tomasrepcik.sensorbox.domain.measurement.StartedPhoneRecording +import com.tomasrepcik.sensorbox.domain.measurement.toWearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome + +internal sealed interface PairedRecordingState { + data object Idle : PairedRecordingState + + data class Starting(val sessionId: String, val includesWatch: Boolean) : PairedRecordingState + + data class Recording(val sessionId: String, val includesWatch: Boolean) : PairedRecordingState + + data class Stopping(val sessionId: String, val includesWatch: Boolean) : PairedRecordingState +} + +internal data class ActivePairedRecording(val sessionId: String, val includesWatch: Boolean) + +internal fun PairedRecordingState.activeRecording(): ActivePairedRecording? = when (this) { + is PairedRecordingState.Starting -> ActivePairedRecording(sessionId, includesWatch) + is PairedRecordingState.Recording -> ActivePairedRecording(sessionId, includesWatch) + is PairedRecordingState.Stopping -> ActivePairedRecording(sessionId, includesWatch) + PairedRecordingState.Idle -> null +} + +internal fun MeasurementRequest.includesWatchRecording(): Boolean = wearSensorIds.isNotEmpty() || wearIncludesGps + +internal fun StartedPhoneRecording.toWatchStartCommand(request: MeasurementRequest) = WearCommand.StartRecording( + sessionId = sessionId, + request = toWearRecordingRequest(request), +) + +internal fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult = + if (outcome == WearRecordingOutcome.SUCCEEDED) { + AppResult.success(Unit) + } else { + AppResult.failure( + AppError( + code = errorCode ?: AppErrorCode.UNKNOWN, + operation = errorOperation ?: "Handle Wear $action result", + diagnosticMessage = errorMessage ?: "Wear $action failed", + context = errorContext + mapOf( + "source" to "wear", + "sessionId" to sessionId, + "retryCount" to retryCount.toString(), + "failureCount" to failureCount.toString(), + "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), + ), + ), + ) + } + +internal fun WearRecordingAction.timeoutOperation(): String = "Await Wear $name result" diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt index 60b097f..50e6dfc 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt @@ -36,7 +36,7 @@ class PhoneRecordingSessionObserver @Inject constructor( private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) { event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } val reason = event.reason.toAutomaticWearReason() ?: return - pairedRecordingCoordinator.onAutomaticLocalStop(event.sessionId, reason) + pairedRecordingCoordinator.onAutomaticPhoneStop(event.sessionId, reason) } private fun MeasurementStopReason.toAutomaticWearReason(): WearStopReason? = when (this) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearCommandHandler.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearCommandHandler.kt index fa907e3..6470a02 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearCommandHandler.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearCommandHandler.kt @@ -11,43 +11,37 @@ import com.tomasrepcik.sensorbox.domain.sensors.WearSensorCatalogStore import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearAcknowledgementOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -fun interface PhoneWearCommandPolicy { - suspend fun handle(command: WearCommand): AppResult -} - class PhoneWearCommandHandler @Inject constructor( @ApplicationContext private val context: Context, private val wearSensorCatalog: WearSensorCatalogStore, - private val acknowledgementInbox: WearAcknowledgementInbox, + private val recordingResults: WearRecordingResultInbox, private val pairedRecordingCoordinator: PairedRecordingCoordinator, private val sendCommand: SendWearCommandUseCase, -) : PhoneWearCommandPolicy { - override suspend fun handle(command: WearCommand): AppResult = when (command) { +) { + suspend fun handle(command: WearCommand): AppResult = when (command) { WearCommand.LaunchPhone -> launchPhone() - is WearCommand.SensorList -> { + is WearCommand.AvailableSensors -> { wearSensorCatalog.update(command.sensors) AppResult.success(Unit) } - is WearCommand.Acknowledgement -> { - acknowledgementInbox.publish(command) + is WearCommand.RecordingResult -> { + recordingResults.publish(command) AppResult.success(Unit) } is WearCommand.StopRecording -> stopFromWear(command) - WearCommand.RequestSensorList, + WearCommand.RequestAvailableSensors, WearCommand.SyncMeasurements, - is WearCommand.PrepareRecording, - is WearCommand.CommitRecording, - is WearCommand.AbortRecording, + is WearCommand.StartRecording, -> AppResult.failure(AppError(AppErrorCode.VALIDATION, "Handle unsupported phone Wear command")) } @@ -60,18 +54,22 @@ class PhoneWearCommandHandler @Inject constructor( } private suspend fun stopFromWear(command: WearCommand.StopRecording): AppResult { - val stopResult = pairedRecordingCoordinator.stopFromPeer(command.sessionId, command.reason) - val acknowledgement = WearCommand.Acknowledgement( + val stopResult = pairedRecordingCoordinator.stopFromWatch(command.sessionId, command.reason) + val error = stopResult.errorOrNull() + val result = WearCommand.RecordingResult( sessionId = command.sessionId, - command = WearSessionCommand.STOP, + action = WearRecordingAction.STOP, outcome = if (stopResult.isSuccess) { - WearAcknowledgementOutcome.SUCCEEDED + WearRecordingOutcome.SUCCEEDED } else { - WearAcknowledgementOutcome.FAILED + WearRecordingOutcome.FAILED }, - errorCode = stopResult.errorOrNull()?.code, + errorCode = error?.code, + errorOperation = error?.operation, + errorMessage = error?.diagnosticMessage, + errorContext = error?.context.orEmpty(), failureCount = if (stopResult.isFailure) 1 else 0, ) - return sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, acknowledgement) + return sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, result) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt index 00c46da..3c8fbc1 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt @@ -9,7 +9,7 @@ import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec import javax.inject.Inject class PhoneWearMessageDispatcher @Inject constructor( - private val commandHandler: PhoneWearCommandPolicy, + private val commandHandler: PhoneWearCommandHandler, private val diagnosticLogger: DiagnosticLogger, ) { suspend fun dispatch(path: String, payload: ByteArray): AppResult { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearAcknowledgementInbox.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearAcknowledgementInbox.kt deleted file mode 100644 index 22e3533..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearAcknowledgementInbox.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.paired - -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WearAcknowledgementInbox @Inject constructor() { - private val acknowledgements = ConcurrentHashMap() - private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) - - fun publish(acknowledgement: WearCommand.Acknowledgement) { - acknowledgements[acknowledgement.key()] = acknowledgement - updates.tryEmit(acknowledgement) - } - - fun clear(sessionId: String, command: WearSessionCommand) { - acknowledgements.remove(Key(sessionId, command)) - } - - suspend fun await(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement { - val key = Key(sessionId, command) - acknowledgements[key]?.let { return it } - return updates.first { acknowledgement -> acknowledgement.key() == key } - } - - private fun WearCommand.Acknowledgement.key() = Key(sessionId, command) - - private data class Key(val sessionId: String, val command: WearSessionCommand) - - private companion object { - const val BUFFER_SIZE = 32 - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearRecordingResultInbox.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearRecordingResultInbox.kt new file mode 100644 index 0000000..03412d0 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WearRecordingResultInbox.kt @@ -0,0 +1,38 @@ +package com.tomasrepcik.sensorbox.domain.paired + +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearRecordingResultInbox @Inject constructor() { + private val results = ConcurrentHashMap() + private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) + + fun publish(result: WearCommand.RecordingResult) { + results[result.key()] = result + updates.tryEmit(result) + } + + fun clear(sessionId: String, action: WearRecordingAction) { + results.remove(Key(sessionId, action)) + } + + suspend fun await(sessionId: String, action: WearRecordingAction): WearCommand.RecordingResult { + val key = Key(sessionId, action) + results[key]?.let { return it } + return updates.first { result -> result.key() == key } + } + + private fun WearCommand.RecordingResult.key() = Key(sessionId, action) + + private data class Key(val sessionId: String, val action: WearRecordingAction) + + private companion object { + const val BUFFER_SIZE = 16 + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt index 51b9119..0b9fff2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt @@ -6,10 +6,14 @@ import android.hardware.SensorManager import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class GetAvailableSensorsUseCase @Inject constructor(@ApplicationContext context: Context) { +fun interface AvailableSensorsUseCase { + operator fun invoke(): List +} + +class GetAvailableSensorsUseCase @Inject constructor(@ApplicationContext context: Context) : AvailableSensorsUseCase { private val sensorManager = context.getSystemService(SensorManager::class.java) - operator fun invoke(): List = sensorManager + override fun invoke(): List = sensorManager .getSensorList(Sensor.TYPE_ALL) .filter { it.type in PHONE_SENSOR_TYPES } .distinctBy(Sensor::getType) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt index 371a2b3..0c0c33c 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt @@ -1,5 +1,7 @@ package com.tomasrepcik.sensorbox.domain.sensors +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo + data class SensorDescriptor( val type: Int, val name: String, @@ -22,3 +24,18 @@ enum class SensorReportingMode { SPECIAL_TRIGGER, UNKNOWN, } + +fun WearSensorInfo.toSensorDescriptor() = SensorDescriptor( + type = type, + name = name, + vendor = vendor, + version = version, + stringType = stringType, + maximumRange = maximumRange, + resolution = resolution, + power = power, + minimumDelayMicros = minimumDelayMicros, + maximumDelayMicros = maximumDelayMicros, + reportingMode = reportingMode.toSensorReportingMode(), + isWakeUpSensor = isWakeUpSensor, +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStore.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStore.kt index eb285e5..cbf57ec 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStore.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStore.kt @@ -10,13 +10,18 @@ import javax.inject.Singleton @Singleton class WearSensorCatalogStore @Inject constructor() { private val mutableSensors = MutableStateFlow>(emptyList()) + private val mutableAvailable = MutableStateFlow(false) + val sensors: StateFlow> = mutableSensors.asStateFlow() + val isAvailable: StateFlow = mutableAvailable.asStateFlow() fun update(sensors: List) { mutableSensors.value = sensors.distinctBy(WearSensorInfo::type).sortedBy(WearSensorInfo::name) + mutableAvailable.value = true } fun clear() { mutableSensors.value = emptyList() + mutableAvailable.value = false } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt index 9d48218..772fc12 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt @@ -11,13 +11,20 @@ enum class RecordingMessage { PICK_AT_LEAST_ONE_SOURCE, STORAGE_REQUIRED, PERMISSION_REQUIRED, + WEAR_PERMISSION_REQUIRED, MEASUREMENT_FAILED, } +enum class RecordingSensorSource { + PHONE, + WEAR, +} + data class RecordingState( val sensors: List = emptyList(), val wearSensors: List = emptyList(), val detailsSensorType: Int? = null, + val detailsSensorSource: RecordingSensorSource = RecordingSensorSource.PHONE, val selectedSensorIds: Set = emptySet(), val includesGps: Boolean = false, val selectedWearSensorIds: Set = emptySet(), @@ -35,6 +42,8 @@ data class RecordingState( val session: MeasurementSessionState = MeasurementSessionState.Idle, val elapsedSeconds: Long = 0, val isWearConnected: Boolean = false, + val isStarting: Boolean = false, + val startCountdownSeconds: Int? = null, val message: RecordingMessage = RecordingMessage.NONE, val errorCode: AppErrorCode? = null, ) { @@ -49,7 +58,6 @@ data class RecordingState( wearSensorIds = selectedWearSensorIds, wearIncludesGps = wearIncludesGps, customName = customMeasurementName, - delaySeconds = startDelaySeconds, durationSeconds = durationSeconds.coerceAtLeast(0), notes = notes.lines().map(String::trim).filter(String::isNotEmpty), alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') @@ -64,7 +72,10 @@ sealed interface RecordingIntent { data class Navigate(val route: MainRoute) : RecordingIntent data class ToggleSensor(val sensorId: Int) : RecordingIntent data class ToggleWearSensor(val sensorId: Int) : RecordingIntent - data class OpenSensorDetails(val sensorType: Int?) : RecordingIntent + data class OpenSensorDetails( + val sensorType: Int?, + val source: RecordingSensorSource = RecordingSensorSource.PHONE, + ) : RecordingIntent data object ToggleGps : RecordingIntent data object ToggleWearGps : RecordingIntent data object ChooseStorage : RecordingIntent @@ -103,13 +114,39 @@ object RecordingReducer { ?: reduceSelection(state, intent) ?: reduceConfiguration(state, intent) + fun measurementStartRequested(state: RecordingState, countdownSeconds: Int): RecordingState = state.copy( + isStarting = true, + startCountdownSeconds = countdownSeconds.takeIf { it > 0 }, + message = RecordingMessage.NONE, + errorCode = null, + ) + + fun measurementCountdownChanged(state: RecordingState, seconds: Int?): RecordingState = state.copy( + startCountdownSeconds = seconds, + ) + + fun measurementStartFailed(state: RecordingState): RecordingState = state.copy( + isStarting = false, + startCountdownSeconds = null, + ) + + fun measurementSessionChanged(state: RecordingState, session: MeasurementSessionState): RecordingState = state.copy( + session = session, + elapsedSeconds = 0, + isStarting = if (session is MeasurementSessionState.Running) false else state.isStarting, + startCountdownSeconds = if (session is MeasurementSessionState.Running) null else state.startCountdownSeconds, + ) + private fun reduceNavigation(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { RecordingIntent.ChooseStorage -> RecordingNext(state, RecordingEffect.PickStorageDirectory) is RecordingIntent.Navigate -> RecordingNext(state, RecordingEffect.Navigate(intent.route)) is RecordingIntent.OpenSensorDetails -> RecordingNext( - state.copy(detailsSensorType = intent.sensorType), + state.copy( + detailsSensorType = intent.sensorType, + detailsSensorSource = intent.source, + ), RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), ) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt index 1082f66..b1510b6 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt @@ -1,28 +1,28 @@ package com.tomasrepcik.sensorbox.presentation.main import android.content.Intent -import android.os.SystemClock import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository +import com.tomasrepcik.sensorbox.domain.measurement.DocumentStorageGateway +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementPermissionsUseCase import com.tomasrepcik.sensorbox.domain.measurement.MeasurementRequest -import com.tomasrepcik.sensorbox.domain.measurement.RecordingWorkflowGateway -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.domain.measurement.RecordingControlUseCase +import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase import com.tomasrepcik.sensorbox.domain.sensors.WearSensorCatalogStore +import com.tomasrepcik.sensorbox.domain.sensors.toSensorDescriptor import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -40,15 +41,20 @@ import javax.inject.Inject @Suppress("TooManyFunctions") class RecordingViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, - private val workflow: RecordingWorkflowGateway, + private val availableSensors: AvailableSensorsUseCase, + private val storage: DocumentStorageGateway, + private val permissions: MeasurementPermissionsUseCase, + private val recording: RecordingControlUseCase, private val sessionStore: MeasurementSessionStore, private val observeWearCapability: ObserveWearCapabilityUseCase, - private val sendWearMessage: SendWearMessageUseCase, + private val sendWearCommand: SendWearCommandUseCase, private val wearSensorCatalog: WearSensorCatalogStore, + private val elapsedRealtimeClock: ElapsedRealtimeClock, ) : ViewModel() { private val mutableState = MutableStateFlow(initialState()) private val mutableEffects = Channel(Channel.BUFFERED) private var elapsedJob: Job? = null + private var startJob: Job? = null val state: StateFlow = mutableState.asStateFlow() val effects = mutableEffects.receiveAsFlow() @@ -66,7 +72,7 @@ class RecordingViewModel @Inject constructor( RecordingIntent.StopMeasurement -> stopMeasurement() - is RecordingIntent.AddAnnotation -> workflow.annotate(intent.text).showFailure() + is RecordingIntent.AddAnnotation -> recording.annotate(intent.text).showFailure() is RecordingIntent.SetSamplingPeriod -> updatePreference( AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), @@ -91,9 +97,11 @@ class RecordingViewModel @Inject constructor( } fun handleStorageResult(resultIntent: Intent?) { - val persisted = workflow.persistStorage(resultIntent) + val persisted = resultIntent?.let(storage::persist) ?: AppResult.failure( + AppError(AppErrorCode.STORAGE, "Select recording storage directory"), + ) mutableState.value = state.value.copy( - storagePath = workflow.storagePath(), + storagePath = storage.displayPath().getOrNull(), message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.STORAGE_REQUIRED, errorCode = persisted.errorOrNull()?.code, ) @@ -101,7 +109,7 @@ class RecordingViewModel @Inject constructor( fun handlePermissionResult() { val request = state.value.toMeasurementRequest() - val missing = workflow.missingPermissions(request) + val missing = permissions.missingPermissions(request) if (missing.isEmpty()) { startMeasurement() } else { @@ -110,8 +118,8 @@ class RecordingViewModel @Inject constructor( } private fun initialState() = RecordingState( - sensors = workflow.sensors(), - storagePath = workflow.storagePath(), + sensors = availableSensors(), + storagePath = storage.displayPath().getOrNull(), ) private fun observePreferences() { @@ -145,14 +153,14 @@ class RecordingViewModel @Inject constructor( private fun onSessionChanged(session: MeasurementSessionState) { elapsedJob?.cancel() - mutableState.value = state.value.copy(session = session, elapsedSeconds = 0) + mutableState.value = RecordingReducer.measurementSessionChanged(state.value, session) if (session is MeasurementSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) } private fun startElapsedTicker(startedAt: Long) { elapsedJob = viewModelScope.launch { while (isActive) { - val seconds = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) / 1_000L + val seconds = (elapsedRealtimeClock.nowMillis() - startedAt).coerceAtLeast(0) / 1_000L mutableState.value = state.value.copy(elapsedSeconds = seconds) delay(1_000L) } @@ -167,11 +175,12 @@ class RecordingViewModel @Inject constructor( emit(WearConnection.Disconnected) } .collect { connection -> - mutableState.value = state.value.copy(isWearConnected = connection is WearConnection.Connected) if (connection is WearConnection.Connected) { - WearCommandCodec.encode(WearCommand.RequestSensorList).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } + sendWearCommand( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommand.RequestAvailableSensors, + ) } else { wearSensorCatalog.clear() } @@ -181,15 +190,12 @@ class RecordingViewModel @Inject constructor( private fun observeWearSensors() { viewModelScope.launch { - wearSensorCatalog.sensors.collect { sensors -> + combine(wearSensorCatalog.sensors, wearSensorCatalog.isAvailable) { sensors, isAvailable -> + sensors to isAvailable + }.collect { (sensors, isAvailable) -> mutableState.value = state.value.copy( - wearSensors = sensors.map { sensor -> - SensorDescriptor( - type = sensor.type, - name = sensor.name, - vendor = sensor.vendor, - ) - }, + isWearConnected = isAvailable, + wearSensors = sensors.map { sensor -> sensor.toSensorDescriptor() }, ) } } @@ -203,12 +209,13 @@ class RecordingViewModel @Inject constructor( @Suppress("ReturnCount") private fun startMeasurement() { + if (state.value.isStarting) return val request = state.value.toMeasurementRequest() if (!request.hasAnySource()) { showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) return } - if (!workflow.hasStorage()) { + if (storage.hasStorage().getOrNull() != true) { reduce(RecordingIntent.ChooseStorage) showMessage(RecordingMessage.STORAGE_REQUIRED, AppErrorCode.STORAGE) return @@ -218,24 +225,43 @@ class RecordingViewModel @Inject constructor( mutableState.value = state.value.copy(errorCode = AppErrorCode.PERMISSION) return } - startForegroundMeasurement(request) + startForegroundMeasurement(request, state.value.startDelaySeconds) } private fun requestMissingPermissions(request: MeasurementRequest): Set? { - val permissions = workflow.missingPermissions(request) - return permissions.takeIf(Set::isNotEmpty) + val missingPermissions = permissions.missingPermissions(request) + return missingPermissions.takeIf(Set::isNotEmpty) } - private fun startForegroundMeasurement(request: MeasurementRequest) { - viewModelScope.launch { - val result = workflow.start(request) - if (result.isFailure) showFailure(result.errorOrNull()) + private fun startForegroundMeasurement(request: MeasurementRequest, countdownSeconds: Int) { + mutableState.value = RecordingReducer.measurementStartRequested(state.value, countdownSeconds) + startJob = viewModelScope.launch { + waitForStartCountdown(countdownSeconds) + val result = recording.start(request) + if (result.isFailure) { + mutableState.value = RecordingReducer.measurementStartFailed(state.value) + showFailure(result.errorOrNull()) + } } } + private suspend fun waitForStartCountdown(seconds: Int) { + for (remaining in seconds downTo 1) { + mutableState.value = RecordingReducer.measurementCountdownChanged(state.value, remaining) + delay(1_000L) + } + mutableState.value = RecordingReducer.measurementCountdownChanged(state.value, null) + } + private fun stopMeasurement() { + if (state.value.isStarting && state.value.session is MeasurementSessionState.Idle) { + startJob?.cancel() + startJob = null + mutableState.value = RecordingReducer.measurementStartFailed(state.value) + return + } viewModelScope.launch { - val result = workflow.stop() + val result = recording.stop() if (result.isSuccess) { mutableEffects.send(RecordingEffect.Navigate(MainRoute.RECORD)) } else { @@ -256,9 +282,16 @@ class RecordingViewModel @Inject constructor( if (isFailure) showFailure(errorOrNull()) } - private fun showFailure(error: com.tomasrepcik.sensorbox.core.error.AppError?) { + private fun showFailure(error: AppError?) { mutableState.value = state.value.copy( - message = RecordingMessage.MEASUREMENT_FAILED, + message = when { + error?.code == AppErrorCode.PERMISSION && error.context["source"] == "wear" -> + RecordingMessage.WEAR_PERMISSION_REQUIRED + + error?.code == AppErrorCode.PERMISSION -> RecordingMessage.PERMISSION_REQUIRED + + else -> RecordingMessage.MEASUREMENT_FAILED + }, errorCode = error?.code ?: AppErrorCode.UNKNOWN, ) } diff --git a/build.gradle.kts b/build.gradle.kts index 19715f5..590839f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.oss.licenses) apply false alias(libs.plugins.screenshot) apply false } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 7006023..4c3690c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.serialization) } android { @@ -29,6 +30,7 @@ dependencies { implementation(libs.androidx.documentfile) implementation(libs.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) testFixturesImplementation(libs.coroutines.core) testImplementation(libs.coroutines.test) diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt index 526c1df..2202eb2 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt @@ -9,9 +9,13 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.io.File import kotlin.time.Instant +@Serializable data class DiagnosticMetadata( val appVersion: String, val buildType: String, @@ -37,7 +41,7 @@ class FileDiagnostics internal constructor( private var uncaughtHandlerInstalled = false override fun record(event: DiagnosticEvent) { - val entry = event.toDiagnosticEntry().take(MAX_ENTRY_CHARS) + val entry = JSON.encodeToString(event.toStoredEntry()) + "\n" synchronized(fileLock) { appendSafely(entry) } @@ -96,29 +100,22 @@ class FileDiagnostics internal constructor( } } - private fun DiagnosticEvent.toDiagnosticEntry(): String = buildString { - val timestamp = ClockFormats.diagnosticTimestamp(clock.nowMillis()) - append(timestamp).append(" | ").append(severity).append(" | ").append(code).append(" | ") - .append(operation.safeText()).appendLine() - append("message=").append(diagnosticMessage.safeText()).appendLine() - append("appVersion=").append(metadata.appVersion.safeText()).appendLine() - append("buildType=").append(metadata.buildType.safeText()).appendLine() - append("deviceModel=").append(metadata.deviceModel.safeText()).appendLine() - append("androidVersion=").append(metadata.androidVersion.safeText()).appendLine() - append("process=").append(metadata.processName.safeText()).appendLine() - context.filterKeys(SAFE_CONTEXT_KEYS::contains).toSortedMap().forEach { (key, value) -> - append(key).append('=').append(value.safeText()).appendLine() - } - cause?.let { error -> - append("exception=").append(error::class.java.name).appendLine() - error.stackTrace.take(MAX_STACK_FRAMES).forEach { frame -> - append("at ").append(frame.className).append('.').append(frame.methodName) - .append('(').append(frame.fileName?.substringAfterLast('/')?.safeText() ?: "Unknown") - .append(':').append(frame.lineNumber).appendLine(")") - } - } - appendLine(ENTRY_SEPARATOR) - } + private fun DiagnosticEvent.toStoredEntry() = StoredDiagnosticEntry( + timestamp = ClockFormats.diagnosticTimestamp(clock.nowMillis()), + severity = severity.name, + code = code.name, + operation = operation.safeText(), + message = diagnosticMessage.safeText(), + metadata = metadata, + context = context.filterKeys(SAFE_CONTEXT_KEYS::contains) + .mapValues { (_, value) -> value.safeText() } + .toSortedMap(), + exception = cause?.javaClass?.name, + stackTrace = cause?.stackTrace.orEmpty().take(MAX_STACK_FRAMES).map { frame -> + "${frame.className}.${frame.methodName}(" + + "${frame.fileName?.substringAfterLast('/')?.safeText() ?: "Unknown"}:${frame.lineNumber})" + }, + ) private fun String.safeText(): String = replace('\n', ' ').replace('\r', ' ').take(MAX_FIELD_CHARS) @@ -162,10 +159,8 @@ class FileDiagnostics internal constructor( private companion object { const val DIRECTORY_NAME = "diagnostics" const val FILE_PREFIX = "sensorbox-diagnostics-" - const val FILE_SUFFIX = ".txt" - const val EXPORT_FILE_NAME = "sensorbox-diagnostics-export.txt" - const val ENTRY_SEPARATOR = "---" - const val MAX_ENTRY_CHARS = 32_000 + const val FILE_SUFFIX = ".jsonl" + const val EXPORT_FILE_NAME = "sensorbox-diagnostics-export.jsonl" const val MAX_FIELD_CHARS = 512 const val MAX_STACK_FRAMES = 80 const val RETENTION_DAYS = 7 @@ -186,5 +181,19 @@ class FileDiagnostics internal constructor( "stopReason", "threadName", ) + val JSON = Json { encodeDefaults = true } } } + +@Serializable +private data class StoredDiagnosticEntry( + val timestamp: String, + val severity: String, + val code: String, + val operation: String, + val message: String, + val metadata: DiagnosticMetadata, + val context: Map, + val exception: String? = null, + val stackTrace: List = emptyList(), +) diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt index 18f3961..702aa18 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt @@ -6,16 +6,10 @@ import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock class RecordingEngine( sources: List, @@ -24,158 +18,77 @@ class RecordingEngine( private val delay: RecordingDelay, ) { private val sourceByType = sources.associateBy(RecordingSource::type) - private val mutex = Mutex() - private val mutableState = MutableStateFlow(RecordingSessionState.Idle) private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) - private var activeSources: List = emptyList() - private var committingSessionId: RecordingSessionId? = null - private var durationJob: Job? = null - private var lastCompletedSessionId: RecordingSessionId? = null + + private var activeRecording: ActiveRecording? = null + private var durationStop: Job? = null + private var lastStoppedSessionId: RecordingSessionId? = null private var lastStopResult: AppResult = AppResult.success(Unit) - val state: StateFlow = mutableState.asStateFlow() val events: SharedFlow = mutableEvents.asSharedFlow() - suspend fun prepare(plan: RecordingPlan): AppResult = mutex.withLock { - if (mutableState.value !is RecordingSessionState.Idle) { - return@withLock conflict("Prepare recording", plan.sessionId) - } + @Suppress("ReturnCount") + suspend fun start(plan: RecordingPlan): AppResult { + if (activeRecording != null) return conflict("Start recording", plan.sessionId) + val validation = validate(plan) - if (validation is AppResult.Failure) return@withLock reject(plan.sessionId, validation.error) + if (validation is AppResult.Failure) return rejectStart(plan.sessionId, validation.error) + + val recording = ActiveRecording(plan) + activeRecording = recording - mutableState.value = RecordingSessionState.Preparing(plan) - val prepared = mutableListOf() for (spec in plan.sources.sortedBy { it.type.ordinal }) { val source = checkNotNull(sourceByType[spec.type]) - when (val result = source.prepare(spec)) { - is AppResult.Success -> prepared += source - - is AppResult.Failure -> { - val sourcesToClean = prepared + source - activeSources = sourcesToClean - return@withLock rejectWithCleanup(plan.sessionId, result.error, sourcesToClean) - } - } - } - activeSources = prepared - mutableState.value = RecordingSessionState.Prepared(plan) - AppResult.success(Unit) - } - - suspend fun commit(sessionId: RecordingSessionId): AppResult = when (val claim = claimCommit(sessionId)) { - is AppResult.Failure -> claim - - is AppResult.Success -> claim.value?.let { plan -> - delay.pause((plan.startAtEpochMillis - clock.epochMillis()).coerceAtLeast(0L)) - startPreparedSources(sessionId, plan) - } ?: AppResult.success(Unit) - } - - private suspend fun claimCommit(sessionId: RecordingSessionId): AppResult = mutex.withLock { - when (val current = mutableState.value) { - is RecordingSessionState.Running -> if (current.plan.sessionId == sessionId) { - AppResult.success(null) - } else { - conflict("Commit recording", sessionId) - } + recording.startedSources += source - is RecordingSessionState.Prepared -> claimPreparedCommit(current, sessionId) - - else -> conflict("Commit recording", sessionId) + val result = source.start(spec) + if (result is AppResult.Failure) return stopFailedStart(recording, result.error) + if (activeRecording !== recording) return AppResult.success(Unit) } - } - - private fun claimPreparedCommit( - current: RecordingSessionState.Prepared, - sessionId: RecordingSessionId, - ): AppResult = when { - current.plan.sessionId != sessionId -> conflict("Commit recording", sessionId) - committingSessionId == sessionId -> AppResult.success(null) - - else -> { - committingSessionId = sessionId - AppResult.success(current.plan) - } + val startedAt = clock.epochMillis() + mutableEvents.tryEmit(RecordingEvent.RecordingStarted(plan.sessionId, startedAt)) + scheduleDurationStop(plan) + return AppResult.success(Unit) } - private suspend fun startPreparedSources(sessionId: RecordingSessionId, plan: RecordingPlan): AppResult = - mutex.withLock { - val prepared = mutableState.value as? RecordingSessionState.Prepared - if (prepared?.plan?.sessionId != sessionId) { - return@withLock conflict("Commit recording", sessionId) - } - - for (source in activeSources) { - when (val result = source.start()) { - is AppResult.Success -> Unit - - is AppResult.Failure -> { - committingSessionId = null - return@withLock rejectWithCleanup(sessionId, result.error, activeSources) - } - } - } - committingSessionId = null - val startedAt = clock.epochMillis() - mutableState.value = RecordingSessionState.Running(plan, startedAt) - mutableEvents.tryEmit(RecordingEvent.RecordingStarted(sessionId, startedAt)) - scheduleDurationStop(plan) - AppResult.success(Unit) + suspend fun stop(sessionId: RecordingSessionId, reason: RecordingStopReason): AppResult { + val recording = activeRecording + if (recording == null) { + return if (lastStoppedSessionId == sessionId) lastStopResult else conflict("Stop recording", sessionId) } + if (recording.plan.sessionId != sessionId) return conflict("Stop recording", sessionId) - suspend fun abort(sessionId: RecordingSessionId): AppResult = - stop(sessionId, RecordingStopReason.PAIRED_ABORT) - - suspend fun stop(sessionId: RecordingSessionId, reason: RecordingStopReason): AppResult = mutex.withLock { - val current = mutableState.value - if (current is RecordingSessionState.Idle) { - return@withLock if (lastCompletedSessionId == sessionId) { - lastStopResult - } else { - conflict("Stop recording", sessionId) - } - } - if (current.sessionId() != sessionId) return@withLock conflict("Stop recording", sessionId) - - mutableState.value = RecordingSessionState.Stopping(sessionId, reason) - val currentJob = currentCoroutineContext()[Job] - durationJob?.takeIf { it !== currentJob }?.cancel() - durationJob = null - committingSessionId = null - val results = activeSources.asReversed().map { it.stop() } - activeSources = emptyList() - val result = results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") - lastCompletedSessionId = sessionId + activeRecording = null + durationStop?.cancel() + durationStop = null + + val stoppedSources = stopSources(recording.startedSources) + val result = stoppedSources.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") + lastStoppedSessionId = sessionId lastStopResult = result - mutableState.value = RecordingSessionState.Idle mutableEvents.tryEmit(RecordingEvent.RecordingStopped(sessionId, reason, result)) - result + return result } - private suspend fun rejectWithCleanup( - sessionId: RecordingSessionId, - startError: AppError, - sources: List, - ): AppResult { - val cleanup = sources.asReversed().map { it.stop() } - activeSources = emptyList() - val result = (listOf(AppResult.failure(startError)) + cleanup).combineAppResults( - AppErrorCode.MEASUREMENT, - "Reject recording start", - ) - return reject(sessionId, checkNotNull(result.errorOrNull())) + private suspend fun stopFailedStart(recording: ActiveRecording, startError: AppError): AppResult { + if (activeRecording === recording) activeRecording = null + val stoppedSources = stopSources(recording.startedSources) + val result = (listOf(AppResult.failure(startError)) + stoppedSources) + .combineAppResults(AppErrorCode.MEASUREMENT, "Stop failed recording start") + return rejectStart(recording.plan.sessionId, checkNotNull(result.errorOrNull())) } - private fun reject(sessionId: RecordingSessionId, error: AppError): AppResult { - committingSessionId = null - mutableState.value = RecordingSessionState.Idle + private suspend fun stopSources(sources: List): List> = + sources.asReversed().map { source -> source.stop() } + + private fun rejectStart(sessionId: RecordingSessionId, error: AppError): AppResult { mutableEvents.tryEmit(RecordingEvent.RecordingStartRejected(sessionId, error)) return AppResult.failure(error) } private fun validate(plan: RecordingPlan): AppResult { - val message = when { + val invalidReason = when { plan.sources.isEmpty() -> "Recording plan has no sources" plan.durationMillis < 0L -> "Recording duration is negative" @@ -183,42 +96,39 @@ class RecordingEngine( plan.sources.map(RecordingSourceSpec::type).distinct().size != plan.sources.size -> "Recording plan repeats a source type" - plan.sources.map(RecordingSourceSpec::type).any { it !in sourceByType } -> - "Recording source adapter is missing" + plan.sources.any { it.type !in sourceByType } -> "Recording source adapter is missing" else -> null } - return message?.let(::validationFailure) ?: AppResult.success(Unit) + + return if (invalidReason == null) { + AppResult.success(Unit) + } else { + AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate recording plan", invalidReason)) + } } - private fun validationFailure(message: String): AppResult = AppResult.failure( - AppError(AppErrorCode.VALIDATION, "Validate recording plan", message), - ) + private fun scheduleDurationStop(plan: RecordingPlan) { + if (plan.durationMillis <= 0L) return + durationStop = scope.launch { + delay.pause(plan.durationMillis) + stop(plan.sessionId, RecordingStopReason.DURATION_EXPIRED) + } + } private fun conflict(operation: String, sessionId: RecordingSessionId): AppResult = AppResult.failure( AppError( code = AppErrorCode.CONFLICT, operation = operation, - diagnosticMessage = "$operation conflicts with the active session", + diagnosticMessage = "$operation conflicts with the active recording", context = mapOf("sessionId" to sessionId.value), ), ) - private fun scheduleDurationStop(plan: RecordingPlan) { - if (plan.durationMillis <= 0L) return - durationJob = scope.launch { - delay.pause(plan.durationMillis) - stop(plan.sessionId, RecordingStopReason.DURATION_EXPIRED) - } - } - - private fun RecordingSessionState.sessionId(): RecordingSessionId? = when (this) { - RecordingSessionState.Idle -> null - is RecordingSessionState.Preparing -> plan.sessionId - is RecordingSessionState.Prepared -> plan.sessionId - is RecordingSessionState.Running -> plan.sessionId - is RecordingSessionState.Stopping -> sessionId - } + private data class ActiveRecording( + val plan: RecordingPlan, + val startedSources: MutableList = mutableListOf(), + ) private companion object { const val EVENT_BUFFER_SIZE = 16 diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt index 842eedd..a81d89f 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt @@ -48,29 +48,15 @@ sealed interface RecordingSourceSpec { data class RecordingPlan( val sessionId: RecordingSessionId, val sources: List, - val startAtEpochMillis: Long, val durationMillis: Long = 0L, ) -sealed interface RecordingSessionState { - data object Idle : RecordingSessionState - - data class Preparing(val plan: RecordingPlan) : RecordingSessionState - - data class Prepared(val plan: RecordingPlan) : RecordingSessionState - - data class Running(val plan: RecordingPlan, val startedAtEpochMillis: Long) : RecordingSessionState - - data class Stopping(val sessionId: RecordingSessionId, val reason: RecordingStopReason) : RecordingSessionState -} - enum class RecordingStopReason { USER_REQUEST, DURATION_EXPIRED, LOW_BATTERY, SOURCE_FAILURE, PLATFORM_DESTROYED, - PAIRED_ABORT, } sealed interface RecordingEvent { diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt index 34ebde1..be905f9 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt @@ -5,9 +5,7 @@ import com.tomasrepcik.sensorbox.core.error.AppResult interface RecordingSource { val type: RecordingSourceType - suspend fun prepare(spec: RecordingSourceSpec): AppResult - - suspend fun start(): AppResult + suspend fun start(spec: RecordingSourceSpec): AppResult suspend fun stop(): AppResult } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt index c97c541..088d7d4 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt @@ -1,7 +1,6 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers import android.annotation.SuppressLint -import android.content.Context import android.location.Location import android.os.Looper import android.util.Log @@ -11,7 +10,6 @@ import com.google.android.gms.location.LocationAvailability import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode @@ -20,43 +18,28 @@ import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.flatMap @SuppressLint("MissingPermission") -class GPSHandler : LocationCallback() { +class GPSHandler(private val locationClient: FusedLocationProviderClient? = null) : LocationCallback() { private var callback: OnLocationChangedCallback? = null private lateinit var request: LocationRequest - private lateinit var locationClient: FusedLocationProviderClient private var locationAvailability: LocationAvailability? = null private var lastLocation: Location? = null private var registered: Boolean = false - private var firstInit: Boolean = false private var intervalSeconds: Int = DEFAULT_INTERVAL_SECONDS private var minDistanceMeters: Int = DEFAULT_DISTANCE_METERS private val tag = "GPS_location" - /** - * creation of the request and locationClient - * - * @param context - */ - private fun firstInit(context: Context) { - request = createRequest() - locationClient = LocationServices.getFusedLocationProviderClient(context) - firstInit = true - } - /** * calls for last known location and registers location callback * * @param context */ - private fun initialize(context: Context) { - if (!firstInit) { - firstInit(context) - } + private fun initialize(client: FusedLocationProviderClient) { + request = createRequest() - locationClient.lastLocation.addOnSuccessListener { location: Location? -> + client.lastLocation.addOnSuccessListener { location: Location? -> if (location == null) { callback?.onLastLocationSuccess(null) } else { @@ -68,7 +51,7 @@ class GPSHandler : LocationCallback() { callback?.onLastLocationSuccess(null) } - locationClient.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> + client.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> AppError.from(AppErrorCode.MEASUREMENT, "Request GPS updates", error) } registered = true @@ -100,10 +83,10 @@ class GPSHandler : LocationCallback() { fun gpsOff(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Stop GPS updates") { if (registered) { Log.i(tag, "Logging off location") - locationClient.flushLocations().addOnFailureListener { error -> + locationClient?.flushLocations()?.addOnFailureListener { error -> AppError.from(AppErrorCode.MEASUREMENT, "Flush GPS updates", error) } - locationClient.removeLocationUpdates(this).addOnFailureListener { error -> + locationClient?.removeLocationUpdates(this)?.addOnFailureListener { error -> AppError.from(AppErrorCode.MEASUREMENT, "Remove GPS updates", error) } } @@ -135,11 +118,12 @@ class GPSHandler : LocationCallback() { * @param gpsCallback - this object will get access to location and updates, previous is forgotten * */ - fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): AppResult = + fun addCallback(gpsCallback: OnLocationChangedCallback): AppResult = (if (registered) gpsOff() else AppResult.success(Unit)).flatMap { appResult(AppErrorCode.MEASUREMENT, "Register GPS callback") { + val client = checkNotNull(locationClient) { "GPS client is unavailable" } callback = gpsCallback - initialize(context) + initialize(client) gpsCallback.onLocationChanged(lastLocation) } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt index 753c6c9..e6acd93 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt @@ -1,7 +1,6 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers import android.content.Context -import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult @@ -31,16 +30,7 @@ internal class StorageHandler @Inject constructor( ) : MeasurementStorage { override fun createMeasurementDirectory(folderName: String, useInternalStorage: Boolean): AppResult = if (useInternalStorage) { - val directory = internalMeasurementDirectory(folderName) - appResult(AppErrorCode.STORAGE, "Create internal measurement directory") { - directory.exists() || directory.mkdirs() - }.flatMap { created -> - if (created) { - AppResult.success(Unit) - } else { - AppResult.failure(AppError(AppErrorCode.STORAGE, "Create internal measurement directory")) - } - } + createInternalDirectory(folderName) } else { documentStorage.createMeasurementDirectory(folderName) } @@ -50,25 +40,24 @@ internal class StorageHandler @Inject constructor( mimeType: String, fileName: String, useInternalStorage: Boolean, - ): AppResult = if (useInternalStorage) { - appResult(AppErrorCode.STORAGE, "Prepare internal measurement directory") { - val directory = internalMeasurementDirectory(folderName) - directory to (directory.exists() || directory.mkdirs()) - }.flatMap { (directory, ready) -> - if (!ready) { - AppResult.failure(AppError(AppErrorCode.STORAGE, "Create internal measurement directory")) - } else { - appResult(AppErrorCode.STORAGE, "Open internal measurement file") { - FileOutputStream(File(directory, fileName)) - } + ): AppResult { + if (!useInternalStorage) { + return documentStorage.openMeasurementFile(folderName, mimeType, fileName) + } + + return createInternalDirectory(folderName).flatMap { + appResult(AppErrorCode.STORAGE, "Open internal measurement file") { + FileOutputStream(File(internalMeasurementDirectory(folderName), fileName)) } } - } else { - documentStorage.openMeasurementFile( - measurementName = folderName, - mimeType = mimeType, - fileName = fileName, - ) + } + + private fun createInternalDirectory(folderName: String): AppResult = appResult( + AppErrorCode.STORAGE, + "Create internal measurement directory", + ) { + val directory = internalMeasurementDirectory(folderName) + check(directory.exists() || directory.mkdirs()) { "Internal measurement directory could not be created" } } private fun internalMeasurementDirectory(folderName: String): File { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt index d7247b4..574de38 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -1,6 +1,5 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements -import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock @@ -18,7 +17,11 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** Collects session metadata and writes it once when the recording is closed. */ -internal class ExtraInfoHandler(private val storage: MeasurementStorage, private val clock: EpochClock) { +internal class ExtraInfoHandler( + private val storage: MeasurementStorage, + private val clock: EpochClock, + private val sensorManager: SensorManager, +) { private var config: MeasurementConfig? = null private var startedAtMillis: Long = 0L private var startedAtNanos: Long = 0L @@ -43,7 +46,7 @@ internal class ExtraInfoHandler(private val storage: MeasurementStorage, private triggeredAlarms += timestampMillis } - fun write(context: Context): AppResult { + fun write(): AppResult { val active = config ?: return AppResult.success(Unit) if (written) return AppResult.success(Unit) written = true @@ -56,7 +59,7 @@ internal class ExtraInfoHandler(private val storage: MeasurementStorage, private folder = active.folderName, notes = active.notes, annotations = annotations.toList(), - ranges = sensorRanges(context, active), + ranges = sensorRanges(active), alarms = triggeredAlarms.toList(), configuredAlarmOffsetsSeconds = active.alarmOffsetsSeconds.toList(), durationMillis = active.durationMillis, @@ -78,15 +81,14 @@ internal class ExtraInfoHandler(private val storage: MeasurementStorage, private }.withAppError(AppErrorCode.MEASUREMENT, "Write measurement metadata") } - private fun sensorRanges(context: Context, active: MeasurementConfig): List { - val manager = context.getSystemService(SensorManager::class.java) + private fun sensorRanges(active: MeasurementConfig): List { val rangeIds = if (active.significantMotion) { active.sensorIds + Sensor.TYPE_SIGNIFICANT_MOTION } else { active.sensorIds } return rangeIds.distinct().mapNotNull { type -> - manager.getDefaultSensor(type)?.let { sensor -> + sensorManager.getDefaultSensor(type)?.let { sensor -> SensorRange(sensor = sensor.name, type = type, range = sensor.maximumRange) } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt index 91f4ccc..163aa9a 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt @@ -1,6 +1,5 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements -import android.content.Context import android.location.Location import com.google.android.gms.location.LocationAvailability import com.tomasrepcik.sensorbox.core.error.AppError @@ -84,7 +83,7 @@ internal class GPSMeasurement( * @param context */ - fun start(context: Context): AppResult = gpsHandler.addCallback(context, this) + fun start(): AppResult = gpsHandler.addCallback(this) .withAppError(AppErrorCode.MEASUREMENT, "Start GPS") /** diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt index 9fff455..2f1d143 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt @@ -1,6 +1,5 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements -import android.content.Context import android.hardware.SensorManager import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode @@ -18,6 +17,7 @@ internal class SensorMeasurement( private val storage: MeasurementStorage, private val diagnosticLogger: DiagnosticLogger, private val clock: EpochClock, + private val sensorManager: SensorManager? = null, ) { private val holders = mutableListOf() private var samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST @@ -54,13 +54,9 @@ internal class SensorMeasurement( ).map { SensorHolder(spec, it, diagnosticLogger, clock) } } - fun start(context: Context): AppResult { - val managerResult = appResult(AppErrorCode.MEASUREMENT, "Access sensor manager") { - context.getSystemService(SensorManager::class.java) - } - val sensorManager = managerResult.getOrNull() - ?: return AppResult.failure(checkNotNull(managerResult.errorOrNull())) - .withAppError(AppErrorCode.MEASUREMENT, "Start sensors") + fun start(): AppResult { + val sensorManager = sensorManager + ?: return AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Access sensor manager")) for (holder in holders) { val registration = registerHolder(sensorManager, holder) if (registration.isFailure) { @@ -86,12 +82,11 @@ internal class SensorMeasurement( } } - private fun pause(context: Context): AppResult = appResult( + private fun pause(): AppResult = appResult( AppErrorCode.MEASUREMENT, "Pause sensors", ) { - val sensorManager = context.getSystemService(SensorManager::class.java) - holders.forEach(sensorManager::unregisterListener) + sensorManager?.let { manager -> holders.forEach(manager::unregisterListener) } } private suspend fun save(): AppResult { @@ -100,8 +95,8 @@ internal class SensorMeasurement( return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save sensors") } - suspend fun stop(context: Context): AppResult = listOf( - pause(context), + suspend fun stop(): AppResult = listOf( + pause(), save(), ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop sensors") } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt index 8b35f11..94beef4 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt @@ -1,6 +1,5 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements -import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager import android.hardware.TriggerEvent @@ -17,16 +16,17 @@ import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage import java.io.OutputStream /** Handles Android's one-shot significant-motion trigger and re-arms it after every event. */ -internal class SignificantMotion(private val storage: MeasurementStorage, private val clock: EpochClock) : - TriggerEventListener() { - private var sensorManager: SensorManager? = null +internal class SignificantMotion( + private val storage: MeasurementStorage, + private val clock: EpochClock, + private val sensorManager: SensorManager, +) : TriggerEventListener() { private var sensor: Sensor? = null private var output: OutputStream? = null private var writeFailure: AppError? = null - fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { - sensorManager = context.getSystemService(SensorManager::class.java) - sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) + fun prepare(folderName: String, useInternalStorage: Boolean): AppResult { + sensor = sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) val stream = storage.openMeasurementFile( folderName = folderName, mimeType = "text/csv", @@ -51,7 +51,7 @@ internal class SignificantMotion(private val storage: MeasurementStorage, privat AppErrorCode.MEASUREMENT, "Pause significant motion", ) { - sensor?.let { sensorManager?.cancelTriggerSensor(this, it) } + sensor?.let { sensorManager.cancelTriggerSensor(this, it) } } private suspend fun save(): AppResult { @@ -66,7 +66,6 @@ internal class SignificantMotion(private val storage: MeasurementStorage, privat suspend fun stop(): AppResult { val results = listOf(pause(), save()) sensor = null - sensorManager = null return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop significant motion") } @@ -79,7 +78,7 @@ internal class SignificantMotion(private val storage: MeasurementStorage, privat if (!arm()) AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion") } - private fun arm(): Boolean = sensor?.let { sensorManager?.requestTriggerSensor(this, it) } == true + private fun arm(): Boolean = sensor?.let { sensorManager.requestTriggerSensor(this, it) } == true private companion object { const val FILE_NAME = "significant_motion.csv" diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementIntentFactory.kt index 372fff4..924e37d 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementIntentFactory.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementIntentFactory.kt @@ -23,7 +23,6 @@ class MeasurementIntentFactory @Inject constructor( putExtra(MeasurementService.USE_WAKE_LOCK, request.useWakeLock) putExtra(MeasurementService.GPS_INTERVAL_SECONDS, request.gpsIntervalSeconds) putExtra(MeasurementService.GPS_DISTANCE_METERS, request.gpsMinDistanceMeters) - putExtra(MeasurementService.START_AT_EPOCH_MILLIS, request.startAtEpochMillis) putExtra(MeasurementService.DURATION_MILLIS, request.durationMillis) putStringArrayListExtra(MeasurementService.NOTES, ArrayList(request.notes)) putExtra(MeasurementService.ALARM_OFFSETS_SECONDS, request.alarmOffsetsSeconds.toIntArray()) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt index 3424963..655eca1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt @@ -1,7 +1,5 @@ package com.tomasrepcik.sensorbox.sensorservices.intent -import com.tomasrepcik.sensorbox.core.time.SystemEpochClock - data class MeasurementLaunchRequest( val sessionId: String = java.util.UUID.randomUUID().toString(), val folderName: String, @@ -13,7 +11,6 @@ data class MeasurementLaunchRequest( val useWakeLock: Boolean, val gpsIntervalSeconds: Int, val gpsMinDistanceMeters: Int, - val startAtEpochMillis: Long = SystemEpochClock.nowMillis(), val durationMillis: Long = 0L, val notes: List = emptyList(), val alarmOffsetsSeconds: List = emptyList(), diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt new file mode 100644 index 0000000..e9abc99 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt @@ -0,0 +1,34 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.recording.RecordingSource +import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec +import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionMeasurement +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionPlatform + +internal class ActivityRecordingSource( + private val config: MeasurementConfig, + private val storage: MeasurementStorage, + private val platform: ActivityRecognitionPlatform, +) : RecordingSource { + private var measurement: ActivityRecognitionMeasurement? = null + override val type = RecordingSourceType.ACTIVITY_RECOGNITION + + override suspend fun start(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.ActivityRecognition) { + val recording = ActivityRecognitionMeasurement(spec.periodSeconds, storage, platform) + measurement = recording + recording.prepare(config.folderName, config.useInternalStorage).flatMap { recording.start() } + } else { + invalidSpec(type) + } + + override suspend fun stop(): AppResult { + val result = measurement?.stop() ?: AppResult.success(Unit) + measurement = null + return result + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt index c8f5421..0fa02c4 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt @@ -1,203 +1,51 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import android.content.Context -import android.media.AudioManager -import android.media.ToneGenerator -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import android.hardware.SensorManager +import com.google.android.gms.location.LocationServices import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingSource -import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec -import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionMeasurement -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionPlatform import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.AndroidActivityRecognitionPlatform -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ExtraInfoHandler import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GPSMeasurement import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorMeasurement import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotion internal class AndroidRecordingSources( - private val context: Context, - private val config: MeasurementConfig, + context: Context, + config: MeasurementConfig, storage: MeasurementStorage, diagnosticLogger: DiagnosticLogger, clock: EpochClock, ) { - private val artifacts = SessionArtifacts(context, config, storage, clock) + private val sensorManager = context.getSystemService(SensorManager::class.java) + private val artifacts = SessionArtifacts(config, storage, clock, sensorManager) val sources: List = listOf( - SessionSource(artifacts), - SensorSource(context, config, storage, diagnosticLogger, clock), - GpsSource(context, config, storage, clock), - ActivitySource(config, storage, AndroidActivityRecognitionPlatform(context)), - SignificantMotionSource(context, config, storage, clock), + SessionRecordingSource(artifacts), + SensorRecordingSource( + config, + SensorMeasurement(storage, diagnosticLogger, clock, sensorManager), + ), + GpsRecordingSource( + config, + GPSMeasurement(GPSHandler(LocationServices.getFusedLocationProviderClient(context)), storage, clock), + ), + ActivityRecordingSource( + config, + storage, + AndroidActivityRecognitionPlatform(context.applicationContext), + ), + SignificantMotionRecordingSource( + config, + SignificantMotion(storage, clock, sensorManager), + ), ) fun annotate(timestampMillis: Long, text: String): AppResult = artifacts.annotate(timestampMillis, text) fun playAlarm(): AppResult = artifacts.playAlarm() } - -private class SessionSource(private val artifacts: SessionArtifacts) : RecordingSource { - override val type = RecordingSourceType.SESSION - - override suspend fun prepare(spec: RecordingSourceSpec): AppResult = - if (spec is RecordingSourceSpec.Session) artifacts.prepare() else invalidSpec(type) - - override suspend fun start(): AppResult = AppResult.success(Unit) - - override suspend fun stop(): AppResult = artifacts.stop() -} - -private class SensorSource( - private val context: Context, - private val config: MeasurementConfig, - storage: MeasurementStorage, - diagnosticLogger: DiagnosticLogger, - clock: EpochClock, -) : RecordingSource { - private val measurement = SensorMeasurement(storage, diagnosticLogger, clock) - override val type = RecordingSourceType.SENSOR - - override suspend fun prepare(spec: RecordingSourceSpec): AppResult = - if (spec is RecordingSourceSpec.Sensors) { - measurement.prepare( - folderName = config.folderName, - useInternalStorage = config.useInternalStorage, - sensorTypes = spec.sensorTypes, - samplingPeriod = spec.samplingPeriod, - ) - } else { - invalidSpec(type) - } - - override suspend fun start(): AppResult = measurement.start(context) - - override suspend fun stop(): AppResult = measurement.stop(context) -} - -private class GpsSource( - private val context: Context, - private val config: MeasurementConfig, - storage: MeasurementStorage, - clock: EpochClock, -) : RecordingSource { - private val measurement = GPSMeasurement(GPSHandler(), storage, clock) - override val type = RecordingSourceType.GPS - - override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { - measurement.prepare( - folderName = config.folderName, - useInternalStorage = config.useInternalStorage, - intervalSeconds = spec.intervalSeconds, - minimumDistanceMeters = spec.minimumDistanceMeters, - ) - } else { - invalidSpec(type) - } - - override suspend fun start(): AppResult = measurement.start(context) - - override suspend fun stop(): AppResult = measurement.stop() -} - -private class ActivitySource( - private val config: MeasurementConfig, - private val storage: MeasurementStorage, - private val platform: ActivityRecognitionPlatform, -) : RecordingSource { - private var measurement: ActivityRecognitionMeasurement? = null - override val type = RecordingSourceType.ACTIVITY_RECOGNITION - - override suspend fun prepare(spec: RecordingSourceSpec): AppResult = - if (spec is RecordingSourceSpec.ActivityRecognition) { - ActivityRecognitionMeasurement(spec.periodSeconds, storage, platform).also { measurement = it }.prepare( - config.folderName, - config.useInternalStorage, - ) - } else { - invalidSpec(type) - } - - override suspend fun start(): AppResult = measurement?.start() - ?: invalidSpec(type) - - override suspend fun stop(): AppResult { - val result = measurement?.stop() ?: AppResult.success(Unit) - measurement = null - return result - } -} - -private class SignificantMotionSource( - private val context: Context, - private val config: MeasurementConfig, - storage: MeasurementStorage, - clock: EpochClock, -) : RecordingSource { - private val measurement = SignificantMotion(storage, clock) - override val type = RecordingSourceType.SIGNIFICANT_MOTION - - override suspend fun prepare(spec: RecordingSourceSpec): AppResult = - if (spec is RecordingSourceSpec.SignificantMotion) { - measurement.prepare(context, config.folderName, config.useInternalStorage) - } else { - invalidSpec(type) - } - - override suspend fun start(): AppResult = measurement.start() - - override suspend fun stop(): AppResult = measurement.stop() -} - -private class SessionArtifacts( - private val context: Context, - private val config: MeasurementConfig, - private val storage: MeasurementStorage, - clock: EpochClock, -) { - private val extraInfo = ExtraInfoHandler(storage, clock) - private var toneGenerator: ToneGenerator? = null - - fun prepare(): AppResult { - val directory = storage.createMeasurementDirectory(config.folderName, config.useInternalStorage) - return directory.onSuccess { extraInfo.start(config) } - } - - fun annotate(timestampMillis: Long, text: String): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Add measurement annotation", - ) { - extraInfo.annotate(timestampMillis, text) - } - - fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { - extraInfo.alarmTriggered() - val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } - tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) - } - - fun stop(): AppResult { - val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { - toneGenerator?.release() - toneGenerator = null - } - return listOf(releaseTone, extraInfo.write(context)) - .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") - } - - private companion object { - const val ALARM_DURATION_MILLIS = 1_000 - } -} - -private fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( - AppError(AppErrorCode.VALIDATION, "Prepare $type recording source"), -) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt new file mode 100644 index 0000000..a9a3a1e --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt @@ -0,0 +1,26 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.recording.RecordingSource +import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec +import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GPSMeasurement + +internal class GpsRecordingSource(private val config: MeasurementConfig, private val measurement: GPSMeasurement) : + RecordingSource { + override val type = RecordingSourceType.GPS + + override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { + measurement.prepare( + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + intervalSeconds = spec.intervalSeconds, + minimumDistanceMeters = spec.minimumDistanceMeters, + ).flatMap { measurement.start() } + } else { + invalidSpec(type) + } + + override suspend fun stop(): AppResult = measurement.stop() +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt index 9812374..cf5b8e2 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt @@ -2,7 +2,6 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import android.content.Intent import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService data class MeasurementConfig( @@ -16,7 +15,6 @@ data class MeasurementConfig( val useWakeLock: Boolean, val gpsIntervalSeconds: Int, val gpsMinDistanceMeters: Int, - val startAtEpochMillis: Long, val durationMillis: Long, val notes: List, val alarmOffsetsSeconds: List, @@ -25,7 +23,7 @@ data class MeasurementConfig( val significantMotion: Boolean, ) { companion object { - fun from(intent: Intent, clock: EpochClock): MeasurementConfig = MeasurementConfig( + fun from(intent: Intent): MeasurementConfig = MeasurementConfig( sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), @@ -39,10 +37,6 @@ data class MeasurementConfig( useWakeLock = intent.getBooleanExtra(MeasurementService.USE_WAKE_LOCK, false), gpsIntervalSeconds = intent.getIntExtra(MeasurementService.GPS_INTERVAL_SECONDS, 10), gpsMinDistanceMeters = intent.getIntExtra(MeasurementService.GPS_DISTANCE_METERS, 20), - startAtEpochMillis = intent.getLongExtra( - MeasurementService.START_AT_EPOCH_MILLIS, - clock.nowMillis(), - ), durationMillis = intent.getLongExtra(MeasurementService.DURATION_MILLIS, 0L).coerceAtLeast(0L), notes = intent.getStringArrayListExtra(MeasurementService.NOTES).orEmpty(), alarmOffsetsSeconds = intent.getIntArrayExtra(MeasurementService.ALARM_OFFSETS_SECONDS) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt new file mode 100644 index 0000000..3cc809e --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt @@ -0,0 +1,10 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.recording.RecordingSourceType + +internal fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( + AppError(AppErrorCode.VALIDATION, "Start $type recording source"), +) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt new file mode 100644 index 0000000..fa5a6b5 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt @@ -0,0 +1,28 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.recording.RecordingSource +import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec +import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorMeasurement + +internal class SensorRecordingSource( + private val config: MeasurementConfig, + private val measurement: SensorMeasurement, +) : RecordingSource { + override val type = RecordingSourceType.SENSOR + + override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Sensors) { + measurement.prepare( + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + sensorTypes = spec.sensorTypes, + samplingPeriod = spec.samplingPeriod, + ).flatMap { measurement.start() } + } else { + invalidSpec(type) + } + + override suspend fun stop(): AppResult = measurement.stop() +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt index e1ae1b4..625f0c6 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt @@ -36,13 +36,7 @@ internal class ServiceController( val events: SharedFlow = engine.events - suspend fun prepareAndCommit(): AppResult { - val plan = config.toRecordingPlan(sessionId) - return when (val prepared = engine.prepare(plan)) { - is AppResult.Success -> engine.commit(sessionId) - is AppResult.Failure -> prepared - } - } + suspend fun start(): AppResult = engine.start(config.toRecordingPlan(sessionId)) suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(sessionId, reason) @@ -61,7 +55,6 @@ internal class ServiceController( return RecordingPlan( sessionId = sessionId, sources = specs, - startAtEpochMillis = startAtEpochMillis, durationMillis = durationMillis, ) } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt new file mode 100644 index 0000000..72b2d80 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt @@ -0,0 +1,23 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import android.content.Context +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.sensorservices.handlers.StorageHandler +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +internal class ServiceControllerFactory @Inject constructor( + private val storage: StorageHandler, + private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, +) { + fun create(context: Context, config: MeasurementConfig, scope: CoroutineScope) = ServiceController( + context = context, + config = config, + scope = scope, + storage = storage, + diagnosticLogger = diagnosticLogger, + clock = clock, + ) +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt new file mode 100644 index 0000000..7ff6556 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt @@ -0,0 +1,52 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import android.hardware.SensorManager +import android.media.AudioManager +import android.media.ToneGenerator +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ExtraInfoHandler + +internal class SessionArtifacts( + private val config: MeasurementConfig, + private val storage: MeasurementStorage, + clock: EpochClock, + sensorManager: SensorManager, +) { + private val extraInfo = ExtraInfoHandler(storage, clock, sensorManager) + private var toneGenerator: ToneGenerator? = null + + fun start(): AppResult = storage + .createMeasurementDirectory(config.folderName, config.useInternalStorage) + .onSuccess { extraInfo.start(config) } + + fun annotate(timestampMillis: Long, text: String): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Add measurement annotation", + ) { + extraInfo.annotate(timestampMillis, text) + } + + fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { + extraInfo.alarmTriggered() + val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } + tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) + } + + fun stop(): AppResult { + val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { + toneGenerator?.release() + toneGenerator = null + } + return listOf(releaseTone, extraInfo.write()) + .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") + } + + private companion object { + const val ALARM_DURATION_MILLIS = 1_000 + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt new file mode 100644 index 0000000..20275f1 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt @@ -0,0 +1,15 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.recording.RecordingSource +import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec +import com.tomasrepcik.sensorbox.recording.RecordingSourceType + +internal class SessionRecordingSource(private val artifacts: SessionArtifacts) : RecordingSource { + override val type = RecordingSourceType.SESSION + + override suspend fun start(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.Session) artifacts.start() else invalidSpec(type) + + override suspend fun stop(): AppResult = artifacts.stop() +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt new file mode 100644 index 0000000..742f5d2 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt @@ -0,0 +1,24 @@ +package com.tomasrepcik.sensorbox.sensorservices.serviceController + +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.recording.RecordingSource +import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec +import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotion + +internal class SignificantMotionRecordingSource( + private val config: MeasurementConfig, + private val measurement: SignificantMotion, +) : RecordingSource { + override val type = RecordingSourceType.SIGNIFICANT_MOTION + + override suspend fun start(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.SignificantMotion) { + measurement.prepare(config.folderName, config.useInternalStorage).flatMap { measurement.start() } + } else { + invalidSpec(type) + } + + override suspend fun stop(): AppResult = measurement.stop() +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt new file mode 100644 index 0000000..94cd5a0 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt @@ -0,0 +1,115 @@ +package com.tomasrepcik.sensorbox.sensorservices.services + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION +import android.os.Build +import android.os.PowerManager +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.sensorservices.serviceController.MeasurementConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class MeasurementHostResources( + private val service: Service, + private val scope: CoroutineScope, + private val requestStop: (RecordingStopReason) -> Unit, + private val playAlarm: () -> Unit, +) { + private var wakeLock: PowerManager.WakeLock? = null + private var batteryReceiverRegistered = false + private var alarmJobs: List = emptyList() + + private val lowBatteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_BATTERY_LOW) requestStop(RecordingStopReason.LOW_BATTERY) + } + } + + fun start(config: MeasurementConfig) { + promoteToForeground(config) + if (config.useWakeLock) acquireWakeLock() + if (config.stopOnLowBattery) registerLowBatteryReceiver() + } + + fun scheduleAlarms(offsetsSeconds: List) { + alarmJobs = offsetsSeconds.distinct().sorted().map { seconds -> + scope.launch { + delay(seconds * 1_000L) + playAlarm() + } + } + } + + fun release(): AppResult { + alarmJobs.forEach(Job::cancel) + alarmJobs = emptyList() + return listOf(releaseWakeLock(), unregisterLowBatteryReceiver()) + .combineAppResults(AppErrorCode.MEASUREMENT, "Release recording host resources") + } + + fun removeNotification() { + service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + } + + private fun promoteToForeground(config: MeasurementConfig) { + val notification = Notify.createRecordingNotification(service) + if (Build.VERSION.SDK_INT >= 34) { + service.startForeground(NOTIFICATION_ID, notification, foregroundTypes(config)) + } else { + service.startForeground(NOTIFICATION_ID, notification) + } + } + + private fun foregroundTypes(config: MeasurementConfig): Int = if (config.includesGps) { + FOREGROUND_SERVICE_TYPE_HEALTH or FOREGROUND_SERVICE_TYPE_LOCATION + } else { + FOREGROUND_SERVICE_TYPE_HEALTH + } + + private fun acquireWakeLock() { + val powerManager = service.getSystemService(PowerManager::class.java) + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } + } + + private fun registerLowBatteryReceiver() { + val filter = IntentFilter(Intent.ACTION_BATTERY_LOW) + if (Build.VERSION.SDK_INT >= 33) { + service.registerReceiver(lowBatteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + service.registerReceiver(lowBatteryReceiver, filter) + } + batteryReceiverRegistered = true + } + + private fun releaseWakeLock(): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Release measurement wake lock", + ) { + wakeLock?.takeIf(PowerManager.WakeLock::isHeld)?.release() + wakeLock = null + } + + private fun unregisterLowBatteryReceiver(): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Unregister low battery receiver", + ) { + if (batteryReceiverRegistered) service.unregisterReceiver(lowBatteryReceiver) + batteryReceiverRegistered = false + } + + private companion object { + const val NOTIFICATION_ID = 729 + const val WAKE_LOCK_TAG = "SensorBox::Measurement" + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt index e227985..d5fd07e 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt @@ -1,27 +1,19 @@ package com.tomasrepcik.sensorbox.sensorservices.services import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION -import android.os.Build import android.os.IBinder -import android.os.PowerManager import android.os.SystemClock import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingEvent import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.handlers.StorageHandler import com.tomasrepcik.sensorbox.sensorservices.serviceController.MeasurementConfig import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceController +import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceControllerFactory import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopReason @@ -31,7 +23,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -42,10 +33,7 @@ class MeasurementService : Service() { lateinit var sessionStore: MeasurementSessionStore @Inject - internal lateinit var storageHandler: StorageHandler - - @Inject - lateinit var diagnosticLogger: DiagnosticLogger + internal lateinit var controllerFactory: ServiceControllerFactory @Inject lateinit var epochClock: EpochClock @@ -54,15 +42,14 @@ class MeasurementService : Service() { private var controller: ServiceController? = null private var activeConfig: MeasurementConfig? = null private var eventJob: Job? = null - private var wakeLock: PowerManager.WakeLock? = null - private var batteryReceiverRegistered = false - private var alarmJobs: List = emptyList() private var isFinishing = false - - private val lowBatteryReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_LOW) requestStop(RecordingStopReason.LOW_BATTERY) - } + private val hostResources by lazy { + MeasurementHostResources( + service = this, + scope = serviceScope, + requestStop = ::requestStop, + playAlarm = { controller?.playAlarm() }, + ) } override fun onBind(intent: Intent?): IBinder? = null @@ -83,24 +70,16 @@ class MeasurementService : Service() { private fun startRecordingHost(intent: Intent) { appResult(AppErrorCode.MEASUREMENT, "Start recording foreground host") { - val config = MeasurementConfig.from(intent, epochClock) + val config = MeasurementConfig.from(intent) require(config.sessionId.isNotBlank()) { "Recording session ID is missing" } activeConfig = config isFinishing = false - promoteToForeground(config) - configureRuntimeResources(config) - val serviceController = ServiceController( - context = this, - config = config, - scope = serviceScope, - storage = storageHandler, - diagnosticLogger = diagnosticLogger, - clock = epochClock, - ) + hostResources.start(config) + val serviceController = controllerFactory.create(this, config, serviceScope) controller = serviceController observeEngine(serviceController) serviceScope.launch { - val result = serviceController.prepareAndCommit() + val result = serviceController.start() if (result.isFailure && !isFinishing) { finishRecording(MeasurementStopReason.SOURCE_FAILURE, result) } @@ -147,7 +126,7 @@ class MeasurementService : Service() { includesGps = config.includesGps, ), ) - scheduleAlarms(config) + hostResources.scheduleAlarms(config.alarmOffsetsSeconds) } private fun requestStop(reason: RecordingStopReason) { @@ -173,14 +152,14 @@ class MeasurementService : Service() { private fun finishHost(): AppResult { val results = mutableListOf>() - results += releaseRuntimeResources() + results += hostResources.release() activeConfig = null controller = null results += appResult(AppErrorCode.MEASUREMENT, "Publish idle measurement state") { sessionStore.markIdle() } results += appResult(AppErrorCode.MEASUREMENT, "Remove measurement notification") { - stopForeground(STOP_FOREGROUND_REMOVE) + hostResources.removeNotification() } results += appResult(AppErrorCode.MEASUREMENT, "Stop measurement service instance") { stopSelf() @@ -188,65 +167,6 @@ class MeasurementService : Service() { return results.combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording host resources") } - private fun scheduleAlarms(config: MeasurementConfig) { - alarmJobs = config.alarmOffsetsSeconds.distinct().sorted().map { seconds -> - serviceScope.launch { - delay(seconds * 1_000L) - controller?.playAlarm() - } - } - } - - private fun promoteToForeground(config: MeasurementConfig) { - val notification = Notify.createRecordingNotification(this) - if (Build.VERSION.SDK_INT >= 34) { - startForeground(NOTIFICATION_ID, notification, foregroundTypes(config)) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun foregroundTypes(config: MeasurementConfig): Int = if (config.includesGps) { - FOREGROUND_SERVICE_TYPE_HEALTH or FOREGROUND_SERVICE_TYPE_LOCATION - } else { - FOREGROUND_SERVICE_TYPE_HEALTH - } - - private fun configureRuntimeResources(config: MeasurementConfig) { - if (config.useWakeLock) acquireWakeLock() - if (config.stopOnLowBattery) registerLowBatteryReceiver() - } - - private fun acquireWakeLock() { - val powerManager = getSystemService(PowerManager::class.java) - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } - } - - private fun registerLowBatteryReceiver() { - val filter = IntentFilter(Intent.ACTION_BATTERY_LOW) - if (Build.VERSION.SDK_INT >= 33) { - registerReceiver(lowBatteryReceiver, filter, RECEIVER_NOT_EXPORTED) - } else { - registerReceiver(lowBatteryReceiver, filter) - } - batteryReceiverRegistered = true - } - - private fun releaseRuntimeResources(): AppResult { - alarmJobs.forEach(Job::cancel) - alarmJobs = emptyList() - val results = mutableListOf>() - results += appResult(AppErrorCode.MEASUREMENT, "Release measurement wake lock") { - wakeLock?.takeIf(PowerManager.WakeLock::isHeld)?.release() - wakeLock = null - } - results += appResult(AppErrorCode.MEASUREMENT, "Unregister low battery receiver") { - if (batteryReceiverRegistered) unregisterReceiver(lowBatteryReceiver) - batteryReceiverRegistered = false - } - return results.combineAppResults(AppErrorCode.MEASUREMENT, "Release recording host resources") - } - override fun onDestroy() { val config = activeConfig if (config != null && !isFinishing) { @@ -254,7 +174,7 @@ class MeasurementService : Service() { val cleanup = runBlocking(Dispatchers.IO) { controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) } - val resources = releaseRuntimeResources() + val resources = hostResources.release() sessionStore.markIdle() sessionStore.publishStopped( config.sessionId, @@ -271,16 +191,10 @@ class MeasurementService : Service() { } private fun RecordingStopReason.toMeasurementReason(): MeasurementStopReason = when (this) { - RecordingStopReason.USER_REQUEST, - RecordingStopReason.PAIRED_ABORT, - -> MeasurementStopReason.USER_REQUEST - + RecordingStopReason.USER_REQUEST -> MeasurementStopReason.USER_REQUEST RecordingStopReason.DURATION_EXPIRED -> MeasurementStopReason.DURATION_EXPIRED - RecordingStopReason.LOW_BATTERY -> MeasurementStopReason.LOW_BATTERY - RecordingStopReason.SOURCE_FAILURE -> MeasurementStopReason.SOURCE_FAILURE - RecordingStopReason.PLATFORM_DESTROYED -> MeasurementStopReason.SERVICE_DESTROYED } @@ -295,7 +209,6 @@ class MeasurementService : Service() { const val USE_WAKE_LOCK = "USE_WAKE_LOCK" const val GPS_INTERVAL_SECONDS = "GPS_INTERVAL_SECONDS" const val GPS_DISTANCE_METERS = "GPS_DISTANCE_METERS" - const val START_AT_EPOCH_MILLIS = "START_AT_EPOCH_MILLIS" const val DURATION_MILLIS = "DURATION_MILLIS" const val NOTES = "NOTES" const val ALARM_OFFSETS_SECONDS = "ALARM_OFFSETS_SECONDS" @@ -306,8 +219,5 @@ class MeasurementService : Service() { const val ANNOTATION_TIME = "ANNOTATION_TIME" const val ANNOTATION_TEXT = "ANNOTATION_TEXT" const val ACTION_STOP = "com.tomasrepcik.sensorbox.action.STOP_MEASUREMENT" - - private const val NOTIFICATION_ID = 729 - private const val WAKE_LOCK_TAG = "SensorBox::Measurement" } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt new file mode 100644 index 0000000..b642275 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt @@ -0,0 +1,38 @@ +package com.tomasrepcik.sensorbox.communication + +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PhoneRecordingResultInbox @Inject constructor() { + private val results = ConcurrentHashMap() + private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) + + fun publish(result: WearCommand.RecordingResult) { + results[result.key()] = result + updates.tryEmit(result) + } + + fun clear(sessionId: String, action: WearRecordingAction) { + results.remove(Key(sessionId, action)) + } + + suspend fun await(sessionId: String, action: WearRecordingAction): WearCommand.RecordingResult { + val key = Key(sessionId, action) + results[key]?.let { return it } + return updates.first { result -> result.key() == key } + } + + private fun WearCommand.RecordingResult.key() = Key(sessionId, action) + + private data class Key(val sessionId: String, val action: WearRecordingAction) + + private companion object { + const val BUFFER_SIZE = 16 + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearAcknowledgementInbox.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearAcknowledgementInbox.kt deleted file mode 100644 index d84a690..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearAcknowledgementInbox.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tomasrepcik.sensorbox.communication - -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WearAcknowledgementInbox @Inject constructor() { - private val acknowledgements = ConcurrentHashMap() - private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) - - fun publish(acknowledgement: WearCommand.Acknowledgement) { - acknowledgements[acknowledgement.key()] = acknowledgement - updates.tryEmit(acknowledgement) - } - - fun clear(sessionId: String, command: WearSessionCommand) { - acknowledgements.remove(Key(sessionId, command)) - } - - suspend fun await(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement { - val key = Key(sessionId, command) - acknowledgements[key]?.let { return it } - return updates.first { acknowledgement -> acknowledgement.key() == key } - } - - private fun WearCommand.Acknowledgement.key() = Key(sessionId, command) - - private data class Key(val sessionId: String, val command: WearSessionCommand) - - private companion object { - const val BUFFER_SIZE = 32 - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt index bd6f7e6..8fcd36f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt @@ -7,35 +7,43 @@ import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository import com.tomasrepcik.sensorbox.domain.measurement.WearMeasurementPermissionUseCase import com.tomasrepcik.sensorbox.domain.sensors.GetWearSensorsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.toWearSensorInfo import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo import kotlinx.coroutines.flow.first import javax.inject.Inject -interface WearCommandEnvironment { - suspend fun prepare(request: WearRecordingRequest): AppResult +interface WearRecordingRequirementsUseCase { + suspend fun validate(request: WearRecordingRequest): AppResult - fun sensors(): List + fun availableSensors(): List } -class AndroidWearCommandEnvironment @Inject constructor( +class WearRecordingEnvironment @Inject constructor( private val measurementPermissions: WearMeasurementPermissionUseCase, private val preferencesRepository: AppPreferencesRepository, private val getWearSensors: GetWearSensorsUseCase, -) : WearCommandEnvironment { - override suspend fun prepare(request: WearRecordingRequest): AppResult { +) : WearRecordingRequirementsUseCase { + override suspend fun validate(request: WearRecordingRequest): AppResult { val availableSensorIds = getWearSensors().map { it.type }.toSet() if (!availableSensorIds.containsAll(request.sensorIds)) { return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear recording sensors")) } + val missingPermissions = measurementPermissions(request.includesGps) if (missingPermissions.isNotEmpty()) { - return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Prepare Wear recording permissions")) + return AppResult.failure( + AppError( + code = AppErrorCode.PERMISSION, + operation = "Validate Wear recording permissions", + diagnosticMessage = "Wear OS is missing required recording permissions", + context = mapOf("missingPermissions" to missingPermissions.sorted().joinToString()), + ), + ) } + return preferencesRepository.preferences.first() } - override fun sensors(): List = getWearSensors().map { sensor -> - WearSensorInfo(sensor.type, sensor.name, sensor.vendor) - } + override fun availableSensors(): List = getWearSensors().map { sensor -> sensor.toWearSensorInfo() } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt index d65b45b..ab1aef2 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt @@ -3,203 +3,180 @@ package com.tomasrepcik.sensorbox.communication import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingController +import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingControlUseCase import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearAcknowledgementOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject class WearCommandHandler @Inject constructor( - private val recordingController: WearRecordingController, - private val environment: WearCommandEnvironment, + private val recording: WearRecordingControlUseCase, + private val environment: WearRecordingRequirementsUseCase, private val sendCommand: SendWearCommandUseCase, - private val acknowledgementInbox: WearAcknowledgementInbox, + private val phoneResults: PhoneRecordingResultInbox, ) { - private val mutex = Mutex() - private val acknowledgements = mutableMapOf() - private var preparedSession: PreparedSession? = null + private val completedResults = mutableMapOf() private var activeSessionId: String? = null suspend fun handle(command: WearCommand): AppResult = when (command) { - is WearCommand.PrepareRecording -> sendAcknowledgement(prepare(command)) + is WearCommand.StartRecording -> sendResult(start(command)) - is WearCommand.CommitRecording -> sendAcknowledgement(commit(command)) + is WearCommand.StopRecording -> sendResult(stop(command)) - is WearCommand.AbortRecording -> sendAcknowledgement(abort(command)) + WearCommand.RequestAvailableSensors -> sendAvailableSensors() - is WearCommand.StopRecording -> sendAcknowledgement(stop(command)) - - WearCommand.RequestSensorList -> sendSensorList() - - is WearCommand.Acknowledgement -> { - acknowledgementInbox.publish(command) + is WearCommand.RecordingResult -> { + phoneResults.publish(command) AppResult.success(Unit) } WearCommand.LaunchPhone, WearCommand.SyncMeasurements, - is WearCommand.SensorList, + is WearCommand.AvailableSensors, -> AppResult.success(Unit) } - suspend fun onAutomaticStop(reason: com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason): AppResult { - val sessionId = mutex.withLock { - activeSessionId?.also(::clearSession) ?: return AppResult.success(Unit) - } + suspend fun onAutomaticStop(reason: WearStopReason): AppResult { + val sessionId = activeSessionId ?: return AppResult.success(Unit) + activeSessionId = null val command = WearCommand.StopRecording(sessionId, reason) - acknowledgementInbox.clear(sessionId, WearSessionCommand.STOP) - repeat(ATTEMPT_COUNT) { - if (sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, command).isSuccess) { - val acknowledgement = withTimeoutOrNull(STOP_TIMEOUT_MILLIS / ATTEMPT_COUNT) { - acknowledgementInbox.await(sessionId, WearSessionCommand.STOP) - } - if (acknowledgement != null) { - return if (acknowledgement.outcome == WearAcknowledgementOutcome.SUCCEEDED) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError( - acknowledgement.errorCode ?: AppErrorCode.UNKNOWN, - "Propagate automatic Wear stop", - ), - ) + phoneResults.clear(sessionId, WearRecordingAction.STOP) + var lastSendError: AppError? = null + + repeat(ATTEMPT_COUNT) { retryCount -> + when (val sent = sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, command)) { + is AppResult.Failure -> lastSendError = sent.error + + is AppResult.Success -> { + lastSendError = null + val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) { + phoneResults.await(sessionId, WearRecordingAction.STOP) } + if (result != null) return result.toAppResult(retryCount) } } } - return AppResult.failure( + + return lastSendError?.let { error -> AppResult.failure(error) } ?: AppResult.failure( AppError( code = AppErrorCode.TIMEOUT, operation = "Propagate automatic Wear stop", - diagnosticMessage = "Phone stop acknowledgement timed out", - context = mapOf("sessionId" to sessionId, "retryCount" to RETRY_COUNT.toString()), + diagnosticMessage = "Phone stop result timed out", + context = mapOf( + "sessionId" to sessionId, + "retryCount" to RETRY_COUNT.toString(), + ), isRetryable = true, ), ) } - private suspend fun prepare(command: WearCommand.PrepareRecording): WearCommand.Acknowledgement = mutex.withLock { - cached(command.sessionId, WearSessionCommand.PREPARE)?.let { return@withLock it } - val result = validatePrepare(command) - if (result is AppResult.Success) { - preparedSession = PreparedSession(command.sessionId, command.request, result.value) - } - acknowledgement( - sessionId = command.sessionId, - command = WearSessionCommand.PREPARE, - result = result.map { Unit }, - failureOutcome = WearAcknowledgementOutcome.REJECTED, - ) - } + private suspend fun start(command: WearCommand.StartRecording): WearCommand.RecordingResult { + cached(command.sessionId, WearRecordingAction.START)?.let { return it } - private suspend fun validatePrepare(command: WearCommand.PrepareRecording): AppResult { - val occupiedSession = activeSessionId ?: preparedSession?.sessionId - if (occupiedSession != null && occupiedSession != command.sessionId) { - return AppResult.failure(AppError(AppErrorCode.CONFLICT, "Prepare Wear recording")) - } - return environment.prepare(command.request) - } + val result = when (val active = activeSessionId) { + command.sessionId -> AppResult.success(Unit) - private suspend fun commit(command: WearCommand.CommitRecording): WearCommand.Acknowledgement = mutex.withLock { - cached(command.sessionId, WearSessionCommand.COMMIT)?.let { return@withLock it } - val prepared = preparedSession - val result = if (activeSessionId == command.sessionId) { - AppResult.success(Unit) - } else if (prepared?.sessionId != command.sessionId) { - AppResult.failure(AppError(AppErrorCode.CONFLICT, "Commit unprepared Wear recording")) - } else { - recordingController.start( - sessionId = command.sessionId, - request = prepared.request, - preferences = prepared.preferences, - startAtEpochMillis = command.startAtEpochMillis, - ).onSuccess { - activeSessionId = command.sessionId - preparedSession = null + null -> when (val validation = environment.validate(command.request)) { + is AppResult.Failure -> validation + + is AppResult.Success -> recording.start( + sessionId = command.sessionId, + request = command.request, + preferences = validation.value, + ).onSuccess { + activeSessionId = command.sessionId + } } - } - acknowledgement(command.sessionId, WearSessionCommand.COMMIT, result) - } - private suspend fun abort(command: WearCommand.AbortRecording): WearCommand.Acknowledgement = mutex.withLock { - cached(command.sessionId, WearSessionCommand.ABORT)?.let { return@withLock it } - val result = when { - activeSessionId == command.sessionId -> recordingController.stop( - command.sessionId, - com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason.PAIRED_ABORT, + else -> AppResult.failure( + AppError( + code = AppErrorCode.CONFLICT, + operation = "Start Wear recording", + diagnosticMessage = "Another Wear recording is active", + context = mapOf("activeSessionId" to active), + ), ) - - preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit) - - else -> AppResult.success(Unit) } - if (result.isSuccess) clearSession(command.sessionId) - acknowledgement(command.sessionId, WearSessionCommand.ABORT, result) - } - private suspend fun stop(command: WearCommand.StopRecording): WearCommand.Acknowledgement = mutex.withLock { - cached(command.sessionId, WearSessionCommand.STOP)?.let { return@withLock it } - val result = when { - activeSessionId == command.sessionId -> recordingController.stop(command.sessionId, command.reason) - preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit) - else -> AppResult.success(Unit) - } - if (result.isSuccess) clearSession(command.sessionId) - acknowledgement(command.sessionId, WearSessionCommand.STOP, result) + return remember(command.sessionId, WearRecordingAction.START, result) } - private fun acknowledgement( - sessionId: String, - command: WearSessionCommand, - result: AppResult, - failureOutcome: WearAcknowledgementOutcome = WearAcknowledgementOutcome.FAILED, - ): WearCommand.Acknowledgement { - val acknowledgement = WearCommand.Acknowledgement( - sessionId = sessionId, - command = command, - outcome = if (result.isSuccess) WearAcknowledgementOutcome.SUCCEEDED else failureOutcome, - errorCode = result.errorOrNull()?.code, - failureCount = if (result.isFailure) 1 else 0, - ) - acknowledgements[AcknowledgementKey(sessionId, command)] = acknowledgement - return acknowledgement - } + private fun stop(command: WearCommand.StopRecording): WearCommand.RecordingResult { + cached(command.sessionId, WearRecordingAction.STOP)?.let { return it } - private fun cached(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement? = - acknowledgements[AcknowledgementKey(sessionId, command)] + val result = if (activeSessionId == command.sessionId) { + recording.stop(command.sessionId, command.reason).onSuccess { + activeSessionId = null + } + } else { + AppResult.success(Unit) + } - private fun clearSession(sessionId: String) { - if (preparedSession?.sessionId == sessionId) preparedSession = null - if (activeSessionId == sessionId) activeSessionId = null + return remember(command.sessionId, WearRecordingAction.STOP, result) } - private suspend fun sendSensorList(): AppResult = sendCommand( + private suspend fun sendAvailableSensors(): AppResult = sendCommand( PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, - WearCommand.SensorList(environment.sensors()), + WearCommand.AvailableSensors(environment.availableSensors()), ) - private suspend fun sendAcknowledgement(acknowledgement: WearCommand.Acknowledgement): AppResult = - sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, acknowledgement) - - private data class PreparedSession( - val sessionId: String, - val request: WearRecordingRequest, - val preferences: AppPreferences, + private suspend fun sendResult(result: WearCommand.RecordingResult): AppResult = sendCommand( + PHONE_APP_CAPABILITY, + PHONE_MESSAGE_PATH, + result, ) - private data class AcknowledgementKey(val sessionId: String, val command: WearSessionCommand) + private fun remember( + sessionId: String, + action: WearRecordingAction, + result: AppResult, + ): WearCommand.RecordingResult { + val error = result.errorOrNull() + return WearCommand.RecordingResult( + sessionId = sessionId, + action = action, + outcome = if (result.isSuccess) WearRecordingOutcome.SUCCEEDED else WearRecordingOutcome.FAILED, + errorCode = error?.code, + errorOperation = error?.operation, + errorMessage = error?.diagnosticMessage, + errorContext = error?.context.orEmpty(), + failureCount = if (result.isFailure) 1 else 0, + ).also { completedResults[ResultKey(sessionId, action)] = it } + } + + private fun cached(sessionId: String, action: WearRecordingAction): WearCommand.RecordingResult? = + completedResults[ResultKey(sessionId, action)] + + private fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult = + if (outcome == WearRecordingOutcome.SUCCEEDED) { + AppResult.success(Unit) + } else { + AppResult.failure( + AppError( + code = errorCode ?: AppErrorCode.UNKNOWN, + operation = errorOperation ?: "Handle phone $action result", + diagnosticMessage = errorMessage ?: "Phone $action failed", + context = errorContext + mapOf( + "source" to "phone", + "sessionId" to sessionId, + "retryCount" to retryCount.toString(), + "failureCount" to failureCount.toString(), + ), + ), + ) + } + + private data class ResultKey(val sessionId: String, val action: WearRecordingAction) private companion object { - const val STOP_TIMEOUT_MILLIS = 5_000L + const val RESULT_TIMEOUT_MILLIS = 5_000L const val RETRY_COUNT = 2 const val ATTEMPT_COUNT = RETRY_COUNT + 1 } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt index a748891..461469a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt @@ -1,9 +1,9 @@ package com.tomasrepcik.sensorbox.di -import com.tomasrepcik.sensorbox.communication.AndroidWearCommandEnvironment -import com.tomasrepcik.sensorbox.communication.WearCommandEnvironment +import com.tomasrepcik.sensorbox.communication.WearRecordingEnvironment +import com.tomasrepcik.sensorbox.communication.WearRecordingRequirementsUseCase import com.tomasrepcik.sensorbox.domain.measurement.WearMeasurementControlUseCase -import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingController +import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingControlUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -13,8 +13,12 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) abstract class RecordingModule { @Binds - abstract fun bindWearRecordingController(implementation: WearMeasurementControlUseCase): WearRecordingController + abstract fun bindWearRecordingControlUseCase( + implementation: WearMeasurementControlUseCase, + ): WearRecordingControlUseCase @Binds - abstract fun bindWearCommandEnvironment(implementation: AndroidWearCommandEnvironment): WearCommandEnvironment + abstract fun bindWearRecordingRequirementsUseCase( + implementation: WearRecordingEnvironment, + ): WearRecordingRequirementsUseCase } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt index 5168f8a..3238f78 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt @@ -8,7 +8,6 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementIntentFactory import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService @@ -17,13 +16,8 @@ import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -interface WearRecordingController { - fun start( - sessionId: String, - request: WearRecordingRequest, - preferences: AppPreferences, - startAtEpochMillis: Long, - ): AppResult +interface WearRecordingControlUseCase { + fun start(sessionId: String, request: WearRecordingRequest, preferences: AppPreferences): AppResult fun stop(sessionId: String, reason: WearStopReason): AppResult } @@ -31,20 +25,17 @@ interface WearRecordingController { class WearMeasurementControlUseCase @Inject constructor( @ApplicationContext private val context: Context, private val intentFactory: MeasurementIntentFactory, - private val clock: EpochClock, -) : WearRecordingController { +) : WearRecordingControlUseCase { override fun start( sessionId: String, request: WearRecordingRequest, preferences: AppPreferences, - startAtEpochMillis: Long, ): AppResult = start( sessionId = sessionId, sensorIds = request.sensorIds.toSet(), includesGps = request.includesGps, preferences = preferences, folderName = request.folderName, - startAtEpochMillis = startAtEpochMillis, durationMillis = request.durationMillis, ) @@ -54,7 +45,6 @@ class WearMeasurementControlUseCase @Inject constructor( preferences: AppPreferences, sessionId: String = java.util.UUID.randomUUID().toString(), folderName: String = intentFactory.newFolderName(), - startAtEpochMillis: Long = clock.nowMillis(), durationMillis: Long = 0L, ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement start") { val request = MeasurementLaunchRequest( @@ -68,7 +58,6 @@ class WearMeasurementControlUseCase @Inject constructor( useWakeLock = preferences.recording.useWakeLock, gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, - startAtEpochMillis = startAtEpochMillis, durationMillis = durationMillis, ) ContextCompat.startForegroundService(context, intentFactory.create(request)) diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt index f33a5cb..286a2ab 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCase.kt @@ -3,20 +3,18 @@ package com.tomasrepcik.sensorbox.domain.measurement import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.os.Build import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class WearMeasurementPermissionUseCase @Inject constructor(@ApplicationContext private val context: Context) { operator fun invoke(includesGps: Boolean): Set = - requiredWearMeasurementPermissions(includesGps, Build.VERSION.SDK_INT).filterNot(::isGranted).toSet() + requiredWearMeasurementPermissions(includesGps).filterNot(::isGranted).toSet() private fun isGranted(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } -internal fun requiredWearMeasurementPermissions(includesGps: Boolean, sdkInt: Int): Set = buildSet { - if (sdkInt >= 33) add(Manifest.permission.POST_NOTIFICATIONS) +internal fun requiredWearMeasurementPermissions(includesGps: Boolean): Set = buildSet { if (includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWearSensorsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWearSensorsUseCase.kt index 402d9bf..e31633a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWearSensorsUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWearSensorsUseCase.kt @@ -20,6 +20,15 @@ class GetWearSensorsUseCase @Inject constructor(@ApplicationContext context: Con type = type, name = name, vendor = vendor, + version = version, + stringType = stringType, + maximumRange = maximumRange, + resolution = resolution, + power = power, + minimumDelayMicros = minDelay, + maximumDelayMicros = maxDelay, + reportingMode = reportingMode, + isWakeUpSensor = isWakeUpSensor, ) } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorDescriptor.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorDescriptor.kt index 167df58..8122c25 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorDescriptor.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorDescriptor.kt @@ -1,3 +1,33 @@ package com.tomasrepcik.sensorbox.domain.sensors -data class WearSensorDescriptor(val type: Int, val name: String, val vendor: String) +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo + +data class WearSensorDescriptor( + val type: Int, + val name: String, + val vendor: String, + val version: Int, + val stringType: String, + val maximumRange: Float, + val resolution: Float, + val power: Float, + val minimumDelayMicros: Int, + val maximumDelayMicros: Int, + val reportingMode: Int, + val isWakeUpSensor: Boolean, +) + +fun WearSensorDescriptor.toWearSensorInfo() = WearSensorInfo( + type = type, + name = name, + vendor = vendor, + version = version, + stringType = stringType, + maximumRange = maximumRange, + resolution = resolution, + power = power, + minimumDelayMicros = minimumDelayMicros, + maximumDelayMicros = maximumDelayMicros, + reportingMode = reportingMode, + isWakeUpSensor = isWakeUpSensor, +) diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt index fea7e56..ba8e889 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt @@ -2,14 +2,12 @@ package com.tomasrepcik.sensorbox.presentation.phonelaunch import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,7 +18,7 @@ import javax.inject.Inject @HiltViewModel class PhoneLaunchViewModel @Inject constructor( private val observeWearCapability: ObserveWearCapabilityUseCase, - private val sendWearMessage: SendWearMessageUseCase, + private val sendWearCommand: SendWearCommandUseCase, ) : ViewModel() { private val mutableState = MutableStateFlow(PhoneLaunchState()) val state: StateFlow = mutableState.asStateFlow() @@ -43,9 +41,7 @@ class PhoneLaunchViewModel @Inject constructor( private fun sendLaunchMessage() { viewModelScope.launch { - val result = WearCommandCodec.encode(WearCommand.LaunchPhone).suspendFlatMap { payload -> - sendWearMessage(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, payload) - } + val result = sendWearCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, WearCommand.LaunchPhone) accept(PhoneLaunchIntent.LaunchCompleted(result.isSuccess)) } } From da44e461847ff922b3768c68a8c575bc40d2ead8 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Thu, 27 Aug 2026 22:09:49 +0200 Subject: [PATCH 3/4] feat: add measurement browsing and Wear controls --- app/build.gradle.kts | 2 + .../sensorbox/activities/MainActivity.kt | 15 + .../AndroidMeasurementRepository.kt | 251 +++++++++ .../measurements/MeasurementRepository.kt | 53 ++ .../presentation/main/ElapsedRealtimeClock.kt | 12 + .../presentation/main/MainContract.kt | 3 + .../main/MeasurementBrowserContract.kt | 135 +++++ .../main/MeasurementBrowserScreen.kt | 524 ++++++++++++++++++ .../main/MeasurementBrowserViewModel.kt | 124 +++++ .../main/MeasurementSetupScreen.kt | 28 +- .../presentation/main/RecordScreen.kt | 116 +++- .../presentation/main/SensorBoxApp.kt | 35 ++ .../presentation/main/SensorBoxComponents.kt | 19 +- .../main/SensorChartTimeSupport.kt | 32 ++ .../presentation/main/SensorDetailsScreen.kt | 21 +- .../presentation/main/SensorPreviewScreen.kt | 8 + app/src/main/res/values/strings.xml | 45 +- app/src/main/res/values/wear.xml | 3 +- .../dashboard/WearDashboardContract.kt | 11 + .../dashboard/WearDashboardScreen.kt | 8 + .../dashboard/WearDashboardViewModel.kt | 8 +- .../presentation/menu/WearMenuContract.kt | 1 + wear/src/main/res/drawable/ic_sync.xml | 9 + wear/src/main/res/values/wear.xml | 3 +- 24 files changed, 1413 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserContract.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt create mode 100644 wear/src/main/res/drawable/ic_sync.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 891d0ad..9b15af8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,11 +68,13 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) implementation(libs.play.services.wearable) implementation(libs.play.services.location) implementation(libs.coroutines.core) implementation(libs.coroutines.android) + implementation(libs.kotlinx.serialization.json) implementation(libs.hilt.android) ksp(libs.hilt.compiler) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt index 966904c..29c8c65 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt @@ -23,6 +23,8 @@ import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.presentation.main.MainViewModel +import com.tomasrepcik.sensorbox.presentation.main.MeasurementBrowserEffect +import com.tomasrepcik.sensorbox.presentation.main.MeasurementBrowserViewModel import com.tomasrepcik.sensorbox.presentation.main.OnboardingEffect import com.tomasrepcik.sensorbox.presentation.main.OnboardingViewModel import com.tomasrepcik.sensorbox.presentation.main.RecordingEffect @@ -42,6 +44,7 @@ class MainActivity : ComponentActivity() { private val mainViewModel: MainViewModel by viewModels() private val onboardingViewModel: OnboardingViewModel by viewModels() private val recordingViewModel: RecordingViewModel by viewModels() + private val measurementBrowserViewModel: MeasurementBrowserViewModel by viewModels() private val settingsViewModel: SettingsViewModel by viewModels() private var storageRequestOwner = StorageRequestOwner.RECORDING private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -61,6 +64,7 @@ class MainActivity : ComponentActivity() { val mainState by mainViewModel.state.collectAsStateWithLifecycle() val onboardingState by onboardingViewModel.state.collectAsStateWithLifecycle() val recordingState by recordingViewModel.state.collectAsStateWithLifecycle() + val measurementBrowserState by measurementBrowserViewModel.state.collectAsStateWithLifecycle() val settingsState by settingsViewModel.state.collectAsStateWithLifecycle() LaunchedEffect(onboardingViewModel) { onboardingViewModel.effects.collect(::handleOnboardingEffect) @@ -71,6 +75,9 @@ class MainActivity : ComponentActivity() { LaunchedEffect(settingsViewModel) { settingsViewModel.effects.collect(::handleSettingsEffect) } + LaunchedEffect(measurementBrowserViewModel) { + measurementBrowserViewModel.effects.collect(::handleMeasurementBrowserEffect) + } SensorBoxTheme( themeMode = settingsState.preferences.display.themeMode, dynamicColor = settingsState.preferences.display.dynamicColors, @@ -79,10 +86,12 @@ class MainActivity : ComponentActivity() { mainState = mainState, onboardingState = onboardingState, recordingState = recordingState, + measurementBrowserState = measurementBrowserState, settingsState = settingsState, onNavigate = mainViewModel::navigate, onOnboardingIntent = onboardingViewModel::accept, onRecordingIntent = recordingViewModel::accept, + onMeasurementBrowserIntent = measurementBrowserViewModel::onIntent, onSettingsIntent = settingsViewModel::accept, ) } @@ -123,6 +132,12 @@ class MainActivity : ComponentActivity() { } } + private fun handleMeasurementBrowserEffect(effect: MeasurementBrowserEffect) { + when (effect) { + is MeasurementBrowserEffect.Navigate -> mainViewModel.navigate(effect.route) + } + } + private fun openStoragePicker(owner: StorageRequestOwner) { storageRequestOwner = owner appResult(AppErrorCode.EXTERNAL_ACTION, "Open storage picker") { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt new file mode 100644 index 0000000..7db8c67 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt @@ -0,0 +1,251 @@ +package com.tomasrepcik.sensorbox.data.measurements + +import android.content.Context +import androidx.documentfile.provider.DocumentFile +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.suspendAppResult +import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary +import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull +import javax.inject.Inject + +class AndroidMeasurementRepository @Inject constructor(@ApplicationContext private val context: Context) : + MeasurementRepository { + override suspend fun loadMeasurements(): AppResult> = withContext(Dispatchers.IO) { + suspendAppResult(AppErrorCode.STORAGE, "List saved measurements") { + selectedMeasurementsDirectory().listFiles() + .filter(DocumentFile::isDirectory) + .map(::createMeasurementSummary) + .sortedWith( + compareByDescending { it.recordedAtMillis ?: Long.MIN_VALUE } + .thenByDescending(MeasurementSummary::name), + ) + } + } + + override suspend fun loadMeasurementDetails(measurementId: String): AppResult = + withContext(Dispatchers.IO) { + suspendAppResult(AppErrorCode.STORAGE, "Read measurement details") { + val directory = measurementDirectory(measurementId) + val metadataFile = directory.findFile(METADATA_FILE)?.takeIf(DocumentFile::isFile) + val metadata = metadataFile?.let(::readDocumentText).orEmpty() + val files = directory.listFiles() + .filter { it.isFile && it.name != METADATA_FILE } + .mapNotNull(::createMeasurementFileSummary) + .sortedWith(compareBy(MeasurementFileSummary::kind, MeasurementFileSummary::name)) + MeasurementDetails( + summary = createMeasurementSummary(directory), + metadata = parseMetadataEntries(metadata), + files = files, + ) + } + } + + override suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult = + withContext(Dispatchers.IO) { + suspendAppResult(AppErrorCode.STORAGE, "Read measurement file") { + val document = measurementDirectory(measurementId).findFile(fileId) + ?.takeIf(DocumentFile::isFile) + ?: error("Measurement file is unavailable") + when { + document.name.equals(GPS_FILE, ignoreCase = true) -> parseGpsCoordinates(document) + document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> parseSensorSeries(document) + else -> parseTextFile(document) + } + } + } + + private fun selectedMeasurementsDirectory(): DocumentFile { + val permission = context.contentResolver.persistedUriPermissions + .filter { it.isReadPermission } + .maxByOrNull { it.persistedTime } + ?: throw IllegalStateException("Recording directory is not configured") + return DocumentFile.fromTreeUri(context, permission.uri) + ?.takeIf(DocumentFile::isDirectory) + ?: throw IllegalStateException("Recording directory is unavailable") + } + + private fun measurementDirectory(measurementId: String): DocumentFile = selectedMeasurementsDirectory().listFiles() + .firstOrNull { it.isDirectory && it.name == measurementId } + ?: throw IllegalArgumentException("Measurement does not exist") + + private fun createMeasurementSummary(directory: DocumentFile): MeasurementSummary { + val metadata = directory.findFile(METADATA_FILE) + ?.takeIf(DocumentFile::isFile) + ?.let(::readDocumentText) + .orEmpty() + val json = metadata.takeIf(String::isNotBlank)?.let(::parseMetadataObject) + return MeasurementSummary( + id = checkNotNull(directory.name), + name = directory.name.orEmpty(), + recordedAtMillis = json?.get("millis")?.asPrimitive()?.longOrNull, + recordedAtText = json?.get("date")?.asPrimitive()?.contentOrNull, + fileCount = directory.listFiles().count { it.isFile && it.name != METADATA_FILE }, + ) + } + + private fun createMeasurementFileSummary(document: DocumentFile): MeasurementFileSummary? { + val name = document.name ?: return null + val kind = when { + name.equals(GPS_FILE, ignoreCase = true) -> MeasurementFileKind.GPS + name.endsWith(CSV_EXTENSION, ignoreCase = true) -> MeasurementFileKind.SENSOR + else -> MeasurementFileKind.TEXT + } + return MeasurementFileSummary(name, formatFileDisplayName(name), kind, document.length()) + } + + private fun parseMetadataEntries(value: String): List { + if (value.isBlank()) return emptyList() + val result = mutableListOf() + collectMetadataEntries(parseMetadataObject(value), prefix = "", destination = result) + return result + } + + private fun collectMetadataEntries( + element: JsonElement, + prefix: String, + destination: MutableList, + ) { + when (element) { + is JsonObject -> element.forEach { (key, value) -> + collectMetadataEntries(value, prefix.appendMetadataKey(key), destination) + } + + is JsonArray -> element.forEachIndexed { index, value -> + collectMetadataEntries(value, "$prefix[$index]", destination) + } + + is JsonPrimitive -> destination += MeasurementMetadataEntry(prefix, element.content) + } + } + + private fun parseSensorSeries(document: DocumentFile): MeasurementFileContent.SensorSeries { + var columns = emptyList() + val samples = mutableListOf() + var totalSamples = 0 + openDocumentStream(document).bufferedReader().useLines { lines -> + val iterator = lines.iterator() + if (!iterator.hasNext()) return@useLines + val header = iterator.next().split(DELIMITER) + val timestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } ?: 0 + val valueIndexes = header.indices.filter { index -> + index != timestampIndex && header[index] !in NON_VALUE_COLUMNS + } + columns = valueIndexes.map(header::get) + while (iterator.hasNext()) { + parseSensorSample(iterator.next(), timestampIndex, valueIndexes)?.let { sample -> + totalSamples += 1 + retainBounded(samples, sample, totalSamples, MAX_CHART_SAMPLES) + } + } + } + return MeasurementFileContent.SensorSeries(columns, samples, totalSamples > samples.size) + } + + private fun parseSensorSample(line: String, timestampIndex: Int, valueIndexes: List): SensorSeriesSample? { + val fields = line.split(DELIMITER) + val timestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null + val values = valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() } + return values.takeIf { it.size == valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) } + } + + private fun parseGpsCoordinates(document: DocumentFile): MeasurementFileContent.GpsCoordinates { + val coordinates = mutableListOf() + var totalCoordinates = 0 + openDocumentStream(document).bufferedReader().useLines { lines -> + lines.drop(1).forEach { line -> + val fields = line.split(DELIMITER) + val coordinate = GpsCoordinate( + timestampMillis = fields.getOrNull(0)?.toLongOrNull() ?: return@forEach, + latitude = fields.getOrNull(1)?.toDoubleOrNull() ?: return@forEach, + longitude = fields.getOrNull(2)?.toDoubleOrNull() ?: return@forEach, + altitude = fields.getOrNull(3)?.toDoubleOrNull(), + accuracyMeters = fields.getOrNull(4)?.toDoubleOrNull(), + speedMetersPerSecond = fields.getOrNull(5)?.toDoubleOrNull(), + bearingDegrees = fields.getOrNull(6)?.toDoubleOrNull(), + provider = fields.getOrNull(7), + ) + totalCoordinates += 1 + if (coordinates.size < MAX_GPS_COORDINATES) coordinates += coordinate + } + } + return MeasurementFileContent.GpsCoordinates( + coordinates, + truncated = totalCoordinates > coordinates.size, + ) + } + + private fun parseTextFile(document: DocumentFile): MeasurementFileContent.Text { + val text = openDocumentStream(document).bufferedReader().use { reader -> + buildString { + val buffer = CharArray(TEXT_BUFFER_SIZE) + var remaining = MAX_TEXT_CHARACTERS + while (remaining > 0) { + val count = reader.read(buffer, 0, minOf(buffer.size, remaining)) + if (count < 0) break + append(buffer, 0, count) + remaining -= count + } + } + } + return MeasurementFileContent.Text(text, truncated = document.length() > text.length) + } + + private fun readDocumentText(document: DocumentFile): String = openDocumentStream(document) + .bufferedReader() + .use { it.readText() } + + private fun openDocumentStream(document: DocumentFile) = context.contentResolver.openInputStream(document.uri) + ?: throw IllegalStateException("Measurement file cannot be opened") + + private fun parseMetadataObject(value: String): JsonObject = JSON.parseToJsonElement(value).jsonObject + + private fun JsonElement.asPrimitive(): JsonPrimitive? = this as? JsonPrimitive + + private fun String.appendMetadataKey(value: String): String = if (isEmpty()) value else "$this.$value" + + private fun formatFileDisplayName(value: String): String = value.substringBeforeLast('.') + .replace('_', ' ') + .replaceFirstChar(Char::titlecase) + + private fun retainBounded(destination: MutableList, value: T, count: Int, limit: Int) { + if (destination.size < limit) { + destination += value + } else if (count % SAMPLE_REPLACEMENT_INTERVAL == 0) { + destination.removeAt(destination.lastIndex) + destination += value + } + } + + private companion object { + const val METADATA_FILE = "extra.json" + const val GPS_FILE = "gps.csv" + const val CSV_EXTENSION = ".csv" + const val DELIMITER = ';' + const val MAX_CHART_SAMPLES = 2_000 + const val MAX_GPS_COORDINATES = 10_000 + const val MAX_TEXT_CHARACTERS = 100_000 + const val TEXT_BUFFER_SIZE = 4_096 + const val SAMPLE_REPLACEMENT_INTERVAL = 100 + val NON_VALUE_COLUMNS = setOf("t_sensor", "accuracy", "provider") + val JSON = Json { ignoreUnknownKeys = true } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt new file mode 100644 index 0000000..e736c00 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt @@ -0,0 +1,53 @@ +package com.tomasrepcik.sensorbox.domain.measurements + +import com.tomasrepcik.sensorbox.core.error.AppResult + +interface MeasurementRepository { + suspend fun loadMeasurements(): AppResult> + + suspend fun loadMeasurementDetails(measurementId: String): AppResult + + suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult +} + +data class MeasurementSummary( + val id: String, + val name: String, + val recordedAtMillis: Long?, + val recordedAtText: String?, + val fileCount: Int, +) + +data class MeasurementDetails( + val summary: MeasurementSummary, + val metadata: List, + val files: List, +) + +data class MeasurementMetadataEntry(val name: String, val value: String) + +data class MeasurementFileSummary(val id: String, val name: String, val kind: MeasurementFileKind, val sizeBytes: Long) + +enum class MeasurementFileKind { SENSOR, GPS, TEXT } + +sealed interface MeasurementFileContent { + data class SensorSeries(val columns: List, val samples: List, val truncated: Boolean) : + MeasurementFileContent + + data class GpsCoordinates(val coordinates: List, val truncated: Boolean) : MeasurementFileContent + + data class Text(val value: String, val truncated: Boolean) : MeasurementFileContent +} + +data class SensorSeriesSample(val timestampMillis: Long, val values: List) + +data class GpsCoordinate( + val timestampMillis: Long, + val latitude: Double, + val longitude: Double, + val altitude: Double?, + val accuracyMeters: Double?, + val speedMetersPerSecond: Double?, + val bearingDegrees: Double?, + val provider: String?, +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt new file mode 100644 index 0000000..669b523 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt @@ -0,0 +1,12 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.os.SystemClock +import javax.inject.Inject + +fun interface ElapsedRealtimeClock { + fun nowMillis(): Long +} + +class AndroidElapsedRealtimeClock @Inject constructor() : ElapsedRealtimeClock { + override fun nowMillis(): Long = SystemClock.elapsedRealtime() +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt index 0efa99b..3bcd9de 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt @@ -12,6 +12,9 @@ enum class MainRoute : NavKey { SENSOR_DETAILS, SENSOR_PREVIEW, SETUP, + MEASUREMENTS, + MEASUREMENT_DETAILS, + MEASUREMENT_FILE, SETTINGS, DIAGNOSTICS, LICENSES, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserContract.kt new file mode 100644 index 0000000..39679e8 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserContract.kt @@ -0,0 +1,135 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary + +data class MeasurementBrowserState( + val measurements: List = emptyList(), + val selectedMeasurement: MeasurementDetails? = null, + val selectedFile: MeasurementFileSummary? = null, + val selectedFileContent: MeasurementFileContent? = null, + val sensorChartTimeWindow: SensorChartTimeWindow = SensorChartTimeWindow(), + val isLoading: Boolean = false, + val errorCode: AppErrorCode? = null, +) + +data class SensorChartTimeWindow(val startFraction: Float = 0f, val endFraction: Float = 1f) { + val visibleFraction: Float get() = endFraction - startFraction +} + +sealed interface MeasurementBrowserIntent { + data object RefreshMeasurements : MeasurementBrowserIntent + data class OpenMeasurementDetails(val measurementId: String) : MeasurementBrowserIntent + data class OpenMeasurementFile(val fileId: String) : MeasurementBrowserIntent + data class ZoomSensorChartTimeWindow(val zoomFactor: Float, val focalPointFraction: Float) : + MeasurementBrowserIntent + + data class ShiftSensorChartTimeWindow(val visibleWindowFraction: Float) : MeasurementBrowserIntent + data object ShowEntireSensorChartTimeRange : MeasurementBrowserIntent +} + +sealed interface MeasurementBrowserEffect { + data class Navigate(val route: MainRoute) : MeasurementBrowserEffect +} + +internal sealed interface MeasurementBrowserResult { + data object LoadingMeasurements : MeasurementBrowserResult + data object LoadingMeasurementDetails : MeasurementBrowserResult + data object LoadingMeasurementFile : MeasurementBrowserResult + data class MeasurementsLoaded(val measurements: List) : MeasurementBrowserResult + data class MeasurementDetailsLoaded(val details: MeasurementDetails) : MeasurementBrowserResult + data class MeasurementFileLoaded(val file: MeasurementFileSummary, val content: MeasurementFileContent) : + MeasurementBrowserResult + + data class MeasurementsLoadFailed(val errorCode: AppErrorCode) : MeasurementBrowserResult + data class MeasurementDetailsLoadFailed(val errorCode: AppErrorCode) : MeasurementBrowserResult + data class MeasurementFileLoadFailed(val errorCode: AppErrorCode) : MeasurementBrowserResult +} + +internal object MeasurementBrowserReducer { + fun reduce(state: MeasurementBrowserState, result: MeasurementBrowserResult): MeasurementBrowserState = + when (result) { + MeasurementBrowserResult.LoadingMeasurements, + MeasurementBrowserResult.LoadingMeasurementDetails, + MeasurementBrowserResult.LoadingMeasurementFile, + -> state.copy(isLoading = true, errorCode = null) + + is MeasurementBrowserResult.MeasurementsLoaded -> state.copy( + measurements = result.measurements, + isLoading = false, + errorCode = null, + ) + + is MeasurementBrowserResult.MeasurementDetailsLoaded -> state.copy( + selectedMeasurement = result.details, + selectedFile = null, + selectedFileContent = null, + isLoading = false, + errorCode = null, + ) + + is MeasurementBrowserResult.MeasurementFileLoaded -> state.copy( + selectedFile = result.file, + selectedFileContent = result.content, + sensorChartTimeWindow = SensorChartTimeWindow(), + isLoading = false, + errorCode = null, + ) + + is MeasurementBrowserResult.MeasurementsLoadFailed -> state.copy( + isLoading = false, + errorCode = result.errorCode, + ) + + is MeasurementBrowserResult.MeasurementDetailsLoadFailed -> state.copy( + isLoading = false, + errorCode = result.errorCode, + ) + + is MeasurementBrowserResult.MeasurementFileLoadFailed -> state.copy( + isLoading = false, + errorCode = result.errorCode, + ) + } + + fun zoomSensorChartTimeWindow( + state: MeasurementBrowserState, + zoomFactor: Float, + focalPointFraction: Float, + ): MeasurementBrowserState { + if (!zoomFactor.isFinite() || zoomFactor <= 0f) return state + val currentWindow = state.sensorChartTimeWindow + val currentVisibleFraction = currentWindow.visibleFraction.coerceIn(MINIMUM_VISIBLE_TIME_FRACTION, 1f) + val nextVisibleFraction = (currentVisibleFraction / zoomFactor) + .coerceIn(MINIMUM_VISIBLE_TIME_FRACTION, 1f) + val focalFraction = focalPointFraction.coerceIn(0f, 1f) + val focalPosition = currentWindow.startFraction + currentVisibleFraction * focalFraction + val nextStart = (focalPosition - nextVisibleFraction * focalFraction) + .coerceIn(0f, 1f - nextVisibleFraction) + return state.copy( + sensorChartTimeWindow = SensorChartTimeWindow(nextStart, nextStart + nextVisibleFraction), + ) + } + + fun shiftSensorChartTimeWindow( + state: MeasurementBrowserState, + visibleWindowFraction: Float, + ): MeasurementBrowserState { + if (!visibleWindowFraction.isFinite()) return state + val currentWindow = state.sensorChartTimeWindow + val visibleFraction = currentWindow.visibleFraction.coerceIn(MINIMUM_VISIBLE_TIME_FRACTION, 1f) + val nextStart = (currentWindow.startFraction + visibleFraction * visibleWindowFraction) + .coerceIn(0f, 1f - visibleFraction) + return state.copy( + sensorChartTimeWindow = SensorChartTimeWindow(nextStart, nextStart + visibleFraction), + ) + } + + fun showEntireSensorChartTimeRange(state: MeasurementBrowserState): MeasurementBrowserState = + state.copy(sensorChartTimeWindow = SensorChartTimeWindow()) + + private const val MINIMUM_VISIBLE_TIME_FRACTION = 0.01f +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt new file mode 100644 index 0000000..da8edad --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt @@ -0,0 +1,524 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary +import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import java.text.DateFormat +import java.util.Date +import kotlin.math.max + +@Composable +fun MeasurementsScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { onIntent(MeasurementBrowserIntent.RefreshMeasurements) } + SensorBoxBackScreen( + title = stringResource(R.string.measurements), + onBack = onBack, + modifier = modifier, + ) { + when { + state.isLoading && state.measurements.isEmpty() -> item { MeasurementLoading() } + + state.errorCode != null -> item { ArchiveError() } + + state.measurements.isEmpty() -> item { EmptyArchive() } + + else -> state.measurements.forEach { measurement -> + item { + MeasurementRow(measurement) { + onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(measurement.id)) + } + } + } + } + if (state.isLoading && state.measurements.isNotEmpty()) item { MeasurementLoading() } + } +} + +@Composable +fun MeasurementDetailsScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val details = state.selectedMeasurement + SensorBoxBackScreen( + title = stringResource(R.string.measurement_details), + onBack = onBack, + modifier = modifier, + itemSpacing = 0.dp, + ) { + if (details == null) { + item { ArchiveError() } + } else { + measurementDetailsItems(details, state, onIntent) + } + } +} + +private fun LazyListScope.measurementDetailsItems( + details: MeasurementDetails, + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + item { SensorBoxSettingsSection(stringResource(R.string.measurement_summary)) } + item { MeasurementSummaryRows(details) } + item { SensorBoxSettingsSection(stringResource(R.string.measurement_metadata)) } + if (details.metadata.isEmpty()) { + item { Text(stringResource(R.string.measurement_metadata_empty), Modifier.padding(vertical = 16.dp)) } + } else { + details.metadata.forEach { entry -> item { MetadataRow(entry) } } + } + item { SensorBoxSettingsSection(stringResource(R.string.measurement_files)) } + if (details.files.isEmpty()) { + item { Text(stringResource(R.string.measurement_files_empty), Modifier.padding(vertical = 16.dp)) } + } else { + details.files.forEach { file -> + item { MeasurementFileRow(file) { onIntent(MeasurementBrowserIntent.OpenMeasurementFile(file.id)) } } + } + } + if (state.isLoading) item { MeasurementLoading() } + if (state.errorCode != null) item { ArchiveError() } +} + +@Composable +fun MeasurementFileScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val file = state.selectedFile + val content = state.selectedFileContent + SensorBoxBackScreen( + title = file?.name ?: stringResource(R.string.measurement_file), + onBack = onBack, + modifier = modifier, + ) { + when (content) { + is MeasurementFileContent.SensorSeries -> { + item { SensorSeriesSummary(content) } + item { + StoredSensorChart( + columns = content.columns, + samples = content.samples, + timeWindow = state.sensorChartTimeWindow, + onIntent = onIntent, + ) + } + } + + is MeasurementFileContent.GpsCoordinates -> { + item { CoordinateSummary(content) } + content.coordinates.forEachIndexed { index, coordinate -> + item { CoordinateRow(index + 1, coordinate) } + } + } + + is MeasurementFileContent.Text -> item { StoredText(content) } + + null -> item { ArchiveError() } + } + } +} + +@Composable +private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) { + Column( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + measurement.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + measurement.recordedAtText?.let { recordedAt -> + Text(recordedAt, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + stringResource(R.string.measurement_file_count, measurement.fileCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + SensorBoxSettingsDivider() +} + +@Composable +private fun MeasurementSummaryRows(details: MeasurementDetails) { + Column { + DetailValueRow(stringResource(R.string.measurement_folder), details.summary.name) + details.summary.recordedAtText?.let { DetailValueRow(stringResource(R.string.measurement_started), it) } + DetailValueRow( + stringResource(R.string.measurement_files), + details.summary.fileCount.toString(), + ) + } +} + +@Composable +private fun MetadataRow(entry: MeasurementMetadataEntry) { + DetailValueRow(metadataLabel(entry.name), entry.value) +} + +@Composable +private fun DetailValueRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Text(label, Modifier.weight(0.42f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, Modifier.weight(0.58f)) + } + SensorBoxSettingsDivider() +} + +@Composable +private fun MeasurementFileRow(file: MeasurementFileSummary, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(file.name, style = MaterialTheme.typography.titleMedium) + Text( + text = when (file.kind) { + MeasurementFileKind.SENSOR -> stringResource(R.string.sensor_samples) + MeasurementFileKind.GPS -> stringResource(R.string.gps_coordinates) + MeasurementFileKind.TEXT -> stringResource(R.string.text_data) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text(formatBytes(file.sizeBytes), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SensorBoxSettingsDivider() +} + +@Composable +private fun SensorSeriesSummary(content: MeasurementFileContent.SensorSeries) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + stringResource(R.string.chart_sample_count, content.samples.size), + style = MaterialTheme.typography.titleMedium, + ) + Text(content.columns.joinToString(), color = MaterialTheme.colorScheme.onSurfaceVariant) + if (content.truncated) { + Text( + stringResource(R.string.chart_sampled_notice), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun StoredSensorChart( + columns: List, + samples: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + if (samples.isEmpty() || columns.isEmpty()) { + Text(stringResource(R.string.no_samples), color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + val colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.secondary, + ) + val orderedSamples = remember(samples) { samples.sortedBy(SensorSeriesSample::timestampMillis) } + val visibleSamples = remember(orderedSamples, timeWindow) { + selectSensorSamplesInTimeWindow(orderedSamples, timeWindow) + } + val fullDurationMillis = (orderedSamples.last().timestampMillis - orderedSamples.first().timestampMillis) + .coerceAtLeast(0L) + val visibleDurationMillis = (fullDurationMillis * timeWindow.visibleFraction).toLong().coerceAtLeast(0L) + val visibleStartTimestamp = orderedSamples.first().timestampMillis + + (fullDurationMillis * timeWindow.startFraction).toLong() + val visibleEndTimestamp = orderedSamples.first().timestampMillis + + (fullDurationMillis * timeWindow.endFraction).toLong() + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Canvas( + Modifier + .fillMaxWidth() + .height(260.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (zoom != 1f) { + onIntent( + MeasurementBrowserIntent.ZoomSensorChartTimeWindow( + zoomFactor = zoom, + focalPointFraction = centroid.x / size.width.coerceAtLeast(1).toFloat(), + ), + ) + } + if (pan.x != 0f) { + onIntent( + MeasurementBrowserIntent.ShiftSensorChartTimeWindow( + visibleWindowFraction = -pan.x / size.width.coerceAtLeast(1).toFloat(), + ), + ) + } + } + }, + ) { + drawStoredChart( + samples = visibleSamples, + columnCount = columns.size, + colors = colors, + visibleStartTimestamp = visibleStartTimestamp, + visibleEndTimestamp = visibleEndTimestamp, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton( + onClick = { + onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(0.5f, 0.5f)) + }, + ) { Text(stringResource(R.string.sensor_chart_zoom_out)) } + TextButton( + onClick = { onIntent(MeasurementBrowserIntent.ShowEntireSensorChartTimeRange) }, + ) { Text(stringResource(R.string.sensor_chart_show_all)) } + TextButton( + onClick = { + onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) + }, + ) { Text(stringResource(R.string.sensor_chart_zoom_in)) } + } + Text( + stringResource( + R.string.sensor_chart_visible_duration, + formatSensorChartDuration(visibleDurationMillis), + formatSensorChartDuration(fullDurationMillis), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + Text( + stringResource(R.string.sensor_chart_gesture_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + columns.forEachIndexed { index, column -> + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.size(10.dp).background(colors[index % colors.size])) + Spacer(Modifier.size(4.dp)) + Text(column, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawStoredChart( + samples: List, + columnCount: Int, + colors: List, + visibleStartTimestamp: Long, + visibleEndTimestamp: Long, +) { + val values = samples.flatMap(SensorSeriesSample::values) + val minimum = values.minOrNull() ?: 0.0 + val maximum = values.maxOrNull() ?: 1.0 + val range = max(maximum - minimum, MINIMUM_CHART_RANGE) + val left = CHART_LABEL_WIDTH + val widthAvailable = size.width - left + val heightAvailable = size.height - CHART_BOTTOM_PADDING + repeat(CHART_GRID_LINES + 1) { line -> + val y = heightAvailable * line / CHART_GRID_LINES + drawLine(Color.Gray.copy(alpha = 0.25f), Offset(left, y), Offset(size.width, y)) + val value = maximum - range * line / CHART_GRID_LINES + drawIntoCanvas { canvas -> + val paint = Paint().apply { + color = Color.Gray.toArgb() + textSize = 10.sp.toPx() + } + canvas.nativeCanvas.drawText(ValueFormats.decimal(value.toFloat()), 4.dp.toPx(), y + 4.dp.toPx(), paint) + } + } + val timestampRange = (visibleEndTimestamp - visibleStartTimestamp).coerceAtLeast(1L) + clipRect(left = left, top = 0f, right = size.width, bottom = heightAvailable) { + repeat(columnCount) { column -> + val path = Path() + var hasPoint = false + samples.forEach { sample -> + val value = sample.values.getOrNull(column) ?: return@forEach + val elapsedFraction = (sample.timestampMillis - visibleStartTimestamp).toFloat() / timestampRange + val x = left + widthAvailable * elapsedFraction + val y = ((maximum - value) / range * heightAvailable).toFloat() + if (!hasPoint) { + path.moveTo(x, y) + hasPoint = true + } else { + path.lineTo(x, y) + } + } + drawPath(path, colors[column % colors.size], style = Stroke(width = 2.dp.toPx())) + } + } +} + +@Composable +private fun CoordinateSummary(content: MeasurementFileContent.GpsCoordinates) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + stringResource(R.string.coordinate_count, content.coordinates.size), + style = MaterialTheme.typography.titleMedium, + ) + if (content.truncated) { + Text( + stringResource(R.string.coordinates_truncated_notice), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CoordinateRow(index: Int, coordinate: GpsCoordinate) { + SensorBoxPanel(Modifier.padding(vertical = 5.dp)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.coordinate_number, index), style = MaterialTheme.typography.titleSmall) + Text( + "${ValueFormats.decimal(coordinate.latitude, GPS_FRACTION_DIGITS)} , " + + ValueFormats.decimal(coordinate.longitude, GPS_FRACTION_DIGITS), + ) + Text( + formatTimestamp(coordinate.timestampMillis), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + coordinate.altitude?.let { + Text( + stringResource(R.string.coordinate_altitude, ValueFormats.decimal(it)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + coordinate.accuracyMeters?.let { + Text( + stringResource(R.string.coordinate_accuracy, ValueFormats.decimal(it)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun StoredText(content: MeasurementFileContent.Text) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (content.truncated) { + Text( + stringResource(R.string.text_truncated_notice), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text(content.value, fontFamily = FontFamily.Monospace) + } +} + +@Composable +private fun MeasurementLoading() { + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.loading_measurements)) + } +} + +@Composable +private fun ArchiveError() { + Text( + stringResource(R.string.measurements_read_failed), + modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), + color = MaterialTheme.colorScheme.error, + ) +} + +@Composable +private fun EmptyArchive() { + Text( + stringResource(R.string.measurements_empty), + modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun metadataLabel(value: String): String = value.replace('.', ' ').replace('_', ' ') + .replaceFirstChar(Char::titlecase) + +private fun formatBytes(value: Long): String = when { + value >= BYTES_PER_MEGABYTE -> "${value / BYTES_PER_MEGABYTE} MB" + value >= BYTES_PER_KILOBYTE -> "${value / BYTES_PER_KILOBYTE} KB" + else -> "$value B" +} + +private fun formatTimestamp(value: Long): String = DateFormat.getDateTimeInstance().format(Date(value)) + +private const val BYTES_PER_KILOBYTE = 1_024L +private const val BYTES_PER_MEGABYTE = BYTES_PER_KILOBYTE * 1_024L +private const val MINIMUM_CHART_RANGE = 0.0001 +private const val CHART_LABEL_WIDTH = 52f +private const val CHART_BOTTOM_PADDING = 12f +private const val CHART_GRID_LINES = 4 +private const val GPS_FRACTION_DIGITS = 6 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt new file mode 100644 index 0000000..f277b02 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt @@ -0,0 +1,124 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MeasurementBrowserViewModel @Inject constructor(private val repository: MeasurementRepository) : ViewModel() { + private val mutableState = MutableStateFlow(MeasurementBrowserState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + fun onIntent(intent: MeasurementBrowserIntent) { + when (intent) { + MeasurementBrowserIntent.RefreshMeasurements -> refreshMeasurements() + is MeasurementBrowserIntent.OpenMeasurementDetails -> openMeasurementDetails(intent.measurementId) + is MeasurementBrowserIntent.OpenMeasurementFile -> openMeasurementFile(intent.fileId) + is MeasurementBrowserIntent.ZoomSensorChartTimeWindow -> zoomSensorChartTimeWindow(intent) + is MeasurementBrowserIntent.ShiftSensorChartTimeWindow -> shiftSensorChartTimeWindow(intent) + MeasurementBrowserIntent.ShowEntireSensorChartTimeRange -> showEntireSensorChartTimeRange() + } + } + + private fun zoomSensorChartTimeWindow(intent: MeasurementBrowserIntent.ZoomSensorChartTimeWindow) { + mutableState.value = MeasurementBrowserReducer.zoomSensorChartTimeWindow( + state.value, + intent.zoomFactor, + intent.focalPointFraction, + ) + } + + private fun shiftSensorChartTimeWindow(intent: MeasurementBrowserIntent.ShiftSensorChartTimeWindow) { + mutableState.value = MeasurementBrowserReducer.shiftSensorChartTimeWindow( + state.value, + intent.visibleWindowFraction, + ) + } + + private fun showEntireSensorChartTimeRange() { + mutableState.value = MeasurementBrowserReducer.showEntireSensorChartTimeRange(state.value) + } + + private fun refreshMeasurements() { + viewModelScope.launch { + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.LoadingMeasurements, + ) + repository.loadMeasurements().fold( + onSuccess = { measurements -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementsLoaded(measurements), + ) + }, + onFailure = { error -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementsLoadFailed(error.code), + ) + }, + ) + } + } + + private fun openMeasurementDetails(measurementId: String) { + viewModelScope.launch { + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.LoadingMeasurementDetails, + ) + repository.loadMeasurementDetails(measurementId).fold( + onSuccess = { details -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementDetailsLoaded(details), + ) + mutableEffects.trySend(MeasurementBrowserEffect.Navigate(MainRoute.MEASUREMENT_DETAILS)) + }, + onFailure = { error -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementDetailsLoadFailed(error.code), + ) + }, + ) + } + } + + private fun openMeasurementFile(fileId: String) { + val details = state.value.selectedMeasurement ?: return + val file = details.files.firstOrNull { it.id == fileId } ?: return + viewModelScope.launch { + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.LoadingMeasurementFile, + ) + repository.loadMeasurementFile(details.summary.id, fileId).fold( + onSuccess = { content -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementFileLoaded(file, content), + ) + mutableEffects.trySend(MeasurementBrowserEffect.Navigate(MainRoute.MEASUREMENT_FILE)) + }, + onFailure = { error -> + mutableState.value = MeasurementBrowserReducer.reduce( + state.value, + MeasurementBrowserResult.MeasurementFileLoadFailed(error.code), + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreen.kt index 9459d4e..be15281 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -29,9 +28,11 @@ fun MeasurementSetupScreen( modifier: Modifier = Modifier, onBack: () -> Unit = { onIntent(RecordingIntent.ReturnToSensorSelection) }, ) { - Box(modifier.fillMaxSize()) { - MeasurementSetupContent(state, onIntent, onBack) - MeasurementSetupActionBar(state, onIntent, Modifier.align(Alignment.BottomCenter)) + Column(modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { + MeasurementSetupContent(state, onIntent, onBack) + } + MeasurementSetupActionBar(state, onIntent) } } @@ -40,7 +41,7 @@ private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingI SensorBoxBackScreen( title = stringResource(R.string.measurement_setup), onBack = onBack, - bottomPadding = 104.dp, + bottomPadding = 24.dp, itemSpacing = 0.dp, ) { item { SensorBoxSettingsSection(stringResource(R.string.setup_storage_category)) } @@ -244,16 +245,25 @@ private fun MeasurementSetupActionBar( modifier: Modifier = Modifier, ) { val sourceCount = setupSourceCount(state) - Surface(modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + shadowElevation = 8.dp, + ) { Box( Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), contentAlignment = Alignment.Center, ) { SensorBoxPrimaryButton( - label = stringResource(R.string.start_measurement), + label = state.startCountdownSeconds?.let { seconds -> + pluralStringResource(R.plurals.starting_in_seconds, seconds, seconds) + } ?: stringResource( + if (state.isStarting) R.string.starting_recording else R.string.start_measurement, + ), onClick = { onIntent(RecordingIntent.StartMeasurement) }, - modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), - enabled = state.storagePath != null && sourceCount > 0, + modifier = Modifier.fillMaxWidth(), + enabled = state.storagePath != null && sourceCount > 0 && !state.isStarting, + loading = state.isStarting, ) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt index 9274c25..c2f52c8 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Checkbox @@ -37,9 +36,11 @@ import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor @Composable fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize()) { - RecordContent(state, onIntent) - SensorSelectionActionBar(state, onIntent, Modifier.align(Alignment.BottomCenter)) + Column(modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { + RecordContent(state, onIntent) + } + SensorSelectionActionBar(state, onIntent) } } @@ -47,12 +48,15 @@ fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, mod @OptIn(ExperimentalFoundationApi::class) private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { LazyColumn( - contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 112.dp), + contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 24.dp), ) { stickyHeader { Surface(color = MaterialTheme.colorScheme.background) { Column { - RecordHeader { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) } + RecordHeader( + onMeasurements = { onIntent(RecordingIntent.Navigate(MainRoute.MEASUREMENTS)) }, + onOptions = { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) }, + ) Spacer(Modifier.height(18.dp)) } } @@ -82,7 +86,6 @@ private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> GpsRow( selected = state.wearIncludesGps, onToggle = { onIntent(RecordingIntent.ToggleWearGps) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } item { SourceDivider() } @@ -92,7 +95,14 @@ private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> sensor = sensor, selected = sensor.type in state.selectedWearSensorIds, onToggle = { onIntent(RecordingIntent.ToggleWearSensor(sensor.type)) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, + onInfo = { + onIntent( + RecordingIntent.OpenSensorDetails( + sensor.type, + RecordingSensorSource.WEAR, + ), + ) + }, ) SourceDivider() } @@ -113,7 +123,7 @@ private fun WearSectionHeader() { } @Composable -private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) { +private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: (() -> Unit)? = null) { SourceRow( title = stringResource(R.string.gps), icon = R.drawable.ic_source_location, @@ -125,10 +135,17 @@ private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) } @Composable -private fun RecordHeader(onOptions: () -> Unit) { +private fun RecordHeader(onMeasurements: () -> Unit, onOptions: () -> Unit) { SensorBoxTopAppBar( title = stringResource(R.string.sources), actions = { + IconButton(onClick = onMeasurements) { + Icon( + painterResource(R.drawable.ic_baseline_folder), + contentDescription = stringResource(R.string.measurements), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton(onClick = onOptions) { Icon( painterResource(R.drawable.ic_baseline_settings_24), @@ -141,7 +158,12 @@ private fun RecordHeader(onOptions: () -> Unit) { } @Composable -private fun SensorRow(sensor: SensorDescriptor, selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) { +private fun SensorRow( + sensor: SensorDescriptor, + selected: Boolean, + onToggle: () -> Unit, + onInfo: (() -> Unit)? = null, +) { SourceRow( title = sensor.name, icon = sensorIconResource(sensor.type), @@ -159,7 +181,7 @@ private fun SourceRow( selected: Boolean, informationDescription: String, onToggle: () -> Unit, - onInfo: () -> Unit, + onInfo: (() -> Unit)?, ) { Row( Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 16.dp), @@ -174,13 +196,15 @@ private fun SourceRow( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - IconButton(onClick = onInfo) { - Icon( - painterResource(R.drawable.ic_info), - contentDescription = informationDescription, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) + onInfo?.let { showInfo -> + IconButton(onClick = showInfo) { + Icon( + painterResource(R.drawable.ic_info), + contentDescription = informationDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } } Spacer(Modifier.width(4.dp)) Checkbox(checked = selected, onCheckedChange = { onToggle() }) @@ -199,7 +223,11 @@ private fun SensorSelectionActionBar( modifier: Modifier = Modifier, ) { val sensorCount = selectedSourceCount(state) - Surface(modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + shadowElevation = 8.dp, + ) { Box( Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), contentAlignment = Alignment.Center, @@ -207,7 +235,7 @@ private fun SensorSelectionActionBar( SensorBoxPrimaryButton( label = stringResource(R.string.continue_action), onClick = { onIntent(RecordingIntent.OpenMeasurementSetup) }, - modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + modifier = Modifier.fillMaxWidth(), enabled = sensorCount > 0, ) } @@ -224,20 +252,54 @@ private fun selectedSourceCount(state: RecordingState): Int = state.selectedSens @Composable fun RecordingMessageText(message: RecordingMessage) { if (message == RecordingMessage.NONE) return - Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { - Text( - stringResource(messageTextResource(message)), - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(14.dp), - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = null, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = stringResource(messageTitleResource(message)), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(messageTextResource(message)), + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } +@StringRes +private fun messageTitleResource(message: RecordingMessage): Int = when (message) { + RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source_title + RecordingMessage.STORAGE_REQUIRED -> R.string.message_storage_required_title + RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required_title + RecordingMessage.WEAR_PERMISSION_REQUIRED -> R.string.message_wear_permission_required_title + RecordingMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed_title + RecordingMessage.NONE -> R.string.app_name +} + @StringRes private fun messageTextResource(message: RecordingMessage): Int = when (message) { RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source RecordingMessage.STORAGE_REQUIRED -> R.string.message_storage_required RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required + RecordingMessage.WEAR_PERMISSION_REQUIRED -> R.string.message_wear_permission_required RecordingMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed RecordingMessage.NONE -> R.string.app_name } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt index e1d534b..732cddb 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt @@ -38,10 +38,12 @@ fun SensorBoxApp( mainState: MainState, onboardingState: OnboardingState, recordingState: RecordingState, + measurementBrowserState: MeasurementBrowserState, settingsState: SettingsState, onNavigate: (MainRoute) -> Unit, onOnboardingIntent: (OnboardingIntent) -> Unit, onRecordingIntent: (RecordingIntent) -> Unit, + onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, onSettingsIntent: (SettingsIntent) -> Unit, ) { if (!mainState.hasLoadedPreferences) { @@ -53,10 +55,12 @@ fun SensorBoxApp( mainState = mainState, onboardingState = onboardingState, recordingState = recordingState, + measurementBrowserState = measurementBrowserState, settingsState = settingsState, onNavigate = onNavigate, onOnboardingIntent = onOnboardingIntent, onRecordingIntent = onRecordingIntent, + onMeasurementBrowserIntent = onMeasurementBrowserIntent, onSettingsIntent = onSettingsIntent, ) } @@ -66,10 +70,12 @@ private fun SensorBoxNavHost( mainState: MainState, onboardingState: OnboardingState, recordingState: RecordingState, + measurementBrowserState: MeasurementBrowserState, settingsState: SettingsState, onNavigate: (MainRoute) -> Unit, onOnboardingIntent: (OnboardingIntent) -> Unit, onRecordingIntent: (RecordingIntent) -> Unit, + onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, onSettingsIntent: (SettingsIntent) -> Unit, ) { val backStack = rememberNavBackStack(mainState.route) @@ -89,9 +95,11 @@ private fun SensorBoxNavHost( route = route, onboardingState = onboardingState, recordingState = recordingState, + measurementBrowserState = measurementBrowserState, settingsState = settingsState, onOnboardingIntent = onOnboardingIntent, onRecordingIntent = onRecordingIntent, + onMeasurementBrowserIntent = onMeasurementBrowserIntent, onSettingsIntent = onSettingsIntent, onBack = navigateBack, ) @@ -135,6 +143,9 @@ private fun MainRoute.isRootDestination() = when (this) { MainRoute.SENSOR_DETAILS, MainRoute.SENSOR_PREVIEW, MainRoute.SETUP, + MainRoute.MEASUREMENTS, + MainRoute.MEASUREMENT_DETAILS, + MainRoute.MEASUREMENT_FILE, MainRoute.SETTINGS, MainRoute.DIAGNOSTICS, MainRoute.LICENSES, @@ -147,9 +158,11 @@ private fun RouteContent( route: MainRoute, onboardingState: OnboardingState, recordingState: RecordingState, + measurementBrowserState: MeasurementBrowserState, settingsState: SettingsState, onOnboardingIntent: (OnboardingIntent) -> Unit, onRecordingIntent: (RecordingIntent) -> Unit, + onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, onSettingsIntent: (SettingsIntent) -> Unit, onBack: () -> Unit, ) { @@ -182,6 +195,11 @@ private fun RouteContent( ) } + MainRoute.MEASUREMENTS, + MainRoute.MEASUREMENT_DETAILS, + MainRoute.MEASUREMENT_FILE, + -> MeasurementBrowserRoute(route, measurementBrowserState, onMeasurementBrowserIntent, onBack) + MainRoute.LICENSES -> FullScreen { modifier -> OpenSourceLicensesScreen(onBack = onBack, modifier = modifier) } @@ -212,6 +230,23 @@ private fun RouteContent( } } +@Composable +private fun MeasurementBrowserRoute( + route: MainRoute, + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, +) { + FullScreen { modifier -> + when (route) { + MainRoute.MEASUREMENTS -> MeasurementsScreen(state, onIntent, onBack, modifier) + MainRoute.MEASUREMENT_DETAILS -> MeasurementDetailsScreen(state, onIntent, onBack, modifier) + MainRoute.MEASUREMENT_FILE -> MeasurementFileScreen(state, onIntent, onBack, modifier) + else -> error("Not a measurement archive route") + } + } +} + private fun forwardScreenTransition(): ContentTransform = (slideInHorizontally(screenTween()) { width -> width / SCREEN_SLIDE_DIVISOR } + fadeIn(screenFadeInTween())) .togetherWith( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt index 3e23f1c..2a440ab 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -150,11 +151,17 @@ fun SensorBoxPanel(modifier: Modifier = Modifier, content: @Composable () -> Uni } @Composable -fun SensorBoxPrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { +fun SensorBoxPrimaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { Button( onClick = onClick, modifier = modifier.height(52.dp), - enabled = enabled, + enabled = enabled && !loading, shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, @@ -163,6 +170,14 @@ fun SensorBoxPrimaryButton(label: String, onClick: () -> Unit, modifier: Modifie disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(10.dp)) + } Text(label, style = MaterialTheme.typography.labelLarge) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt new file mode 100644 index 0000000..7d250b9 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample + +internal fun selectSensorSamplesInTimeWindow( + samples: List, + timeWindow: SensorChartTimeWindow, +): List { + if (samples.size <= 2) return samples + val firstTimestamp = samples.first().timestampMillis + val duration = samples.last().timestampMillis - firstTimestamp + if (duration <= 0L) return samples + val startTimestamp = firstTimestamp + (duration * timeWindow.startFraction).toLong() + val endTimestamp = firstTimestamp + (duration * timeWindow.endFraction).toLong() + val firstVisibleIndex = samples.indexOfFirst { it.timestampMillis >= startTimestamp } + .let { if (it < 0) samples.lastIndex else it } + val lastVisibleIndex = samples.indexOfLast { it.timestampMillis <= endTimestamp } + .let { if (it < 0) 0 else it } + val startIndex = (firstVisibleIndex - 1).coerceAtLeast(0) + val endIndex = (lastVisibleIndex + 1).coerceAtMost(samples.lastIndex).coerceAtLeast(startIndex) + return samples.subList(startIndex, endIndex + 1) +} + +internal fun formatSensorChartDuration(durationMillis: Long): String { + val totalSeconds = durationMillis / MILLIS_PER_SECOND + val minutes = totalSeconds / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE + return if (minutes > 0) "$minutes min $seconds s" else "$seconds s" +} + +private const val MILLIS_PER_SECOND = 1_000L +private const val SECONDS_PER_MINUTE = 60L diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt index e1cf828..b863025 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationServices import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor @@ -45,14 +46,21 @@ fun SensorDetailsScreen( onPreview: () -> Unit, modifier: Modifier = Modifier, ) { - val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } + val sensor = state.detailsSensorType?.let { type -> + val sensors = when (state.detailsSensorSource) { + RecordingSensorSource.PHONE -> state.sensors + RecordingSensorSource.WEAR -> state.wearSensors + } + sensors.firstOrNull { it.type == type } + } val canPreview = state.detailsSensorType == null || sensor?.type != Sensor.TYPE_STEP_DETECTOR val title = when { state.detailsSensorType == null -> stringResource(R.string.gps) sensor != null -> sensor.name else -> stringResource(R.string.sensor_unavailable) } - val showPreview = (state.detailsSensorType == null || sensor != null) && canPreview + val showPreview = state.detailsSensorSource == RecordingSensorSource.PHONE && + (state.detailsSensorType == null || sensor != null) && canPreview Box(modifier.fillMaxSize()) { SensorDetailsList(state, sensor, title, showPreview, onBack) if (showPreview) { @@ -77,7 +85,9 @@ private fun SensorDetailsList( if (state.detailsSensorType == null) { item { GpsDetails(state) } } else if (sensor != null) { - item { HardwareSensorDetails(sensor) } + item { + HardwareSensorDetails(sensor) + } } } } @@ -244,14 +254,15 @@ internal fun rememberGpsDetails( val hasPermission = remember(context, permissionRevision) { context.hasLocationPermission() } var location by remember { mutableStateOf(null) } var isAvailable by remember { mutableStateOf(null) } - val gpsHandler = remember { GPSHandler() } + val gpsHandler = remember(context) { + GPSHandler(LocationServices.getFusedLocationProviderClient(context)) + } DisposableEffect(gpsHandler, hasPermission, intervalSeconds, minimumDistanceMeters) { var active = true if (hasPermission) { gpsHandler.configure(intervalSeconds, minimumDistanceMeters) gpsHandler.addCallback( - context, GpsDetailsCallback( onLocation = { if (active && it != null) location = it }, onAvailability = { if (active) isAvailable = it }, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt index 5366998..0ff584d 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -50,6 +50,14 @@ import kotlin.math.max @Composable fun SensorPreviewScreen(state: RecordingState, onBack: () -> Unit, modifier: Modifier = Modifier) { + if (state.detailsSensorSource == RecordingSensorSource.WEAR) { + SensorBoxBackScreen( + title = stringResource(R.string.sensor_unavailable), + onBack = onBack, + modifier = modifier, + ) {} + return + } val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } val title = when { state.detailsSensorType == null -> stringResource(R.string.gps) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 684bc9d..6fd53c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,12 +96,48 @@ %1$d source %1$d sources + Select a recording source Pick at least one sensor or GPS. + Recording folder required Choose a folder before recording. - Required permission was not granted. - The recording could not be started. + Permission required + Grant the required permission, then try again. + Permission required on Wear OS + Open SensorBox on your watch, grant Location permission, then try again. + Recording could not start + Check diagnostics for details, then try again. Measurements + Measurement details + Summary + Metadata + No metadata file was found. + Measurement files + This measurement contains no readable data files. + Measurement file + Folder + Started + %1$d data files + Sensor samples + GPS coordinates + Text data + Loading measurements… + The selected recording folder could not be read. + No measurements were found in the selected recording folder. + %1$d chart samples + Zoom out + Show all + Zoom in + Showing %1$s of %2$s + Pinch to zoom. Drag horizontally to move through time. + A bounded sample is shown for this large file. + This file contains no samples. + %1$d coordinates + Only the first 10,000 coordinates are shown. + Coordinate %1$d + Altitude: %1$s m + Accuracy: %1$s m + Only the first 100,000 characters are shown. Measurement settings Control how measurements are recorded Measurement setup @@ -111,6 +147,11 @@ Folder required Ready to record Start measurement + Starting recording… + + Starting in %d second… + Starting in %d seconds… + Measurement name Optional folder-name prefix Start delay diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml index 150e618..a5da41c 100644 --- a/app/src/main/res/values/wear.xml +++ b/app/src/main/res/values/wear.xml @@ -1,5 +1,6 @@ - + state } + fun measurementSessionChanged(state: WearDashboardState, session: MeasurementSessionState) = when { + session is MeasurementSessionState.Running -> state.copy(route = WearRoute.ACTIVE) + + session is MeasurementSessionState.Idle && state.route == WearRoute.ACTIVE -> + state.copy(route = WearRoute.MENU) + + else -> state + } + private fun WearDashboardState.selectLiveSensor(sensorType: Int) = copy( route = WearRoute.LIVE, liveSensorType = sensorType, @@ -77,6 +87,7 @@ object WearDashboardReducer { private fun WearMenuDestination.toRoute(): WearRoute = when (this) { WearMenuDestination.RECORD -> WearRoute.RECORD WearMenuDestination.LIVE_SENSOR -> WearRoute.LIVE + WearMenuDestination.SYNC -> WearRoute.SETTINGS WearMenuDestination.SETTINGS -> WearRoute.SETTINGS else -> WearRoute.MENU } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt index 363aea0..568674a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt @@ -19,6 +19,7 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.CircularProgressIndicator import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.OutlinedButton import androidx.wear.compose.material3.ScreenScaffold @@ -179,6 +180,13 @@ private fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboard enabled = !state.isSyncing, ) { accept(WearDashboardIntent.SyncMeasurements) } } + if (state.isSyncing) { + item { + CircularProgressIndicator( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) + } + } state.message?.let { message -> item { WearSectionTitle(wearMessageText(message), transformation) } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt index eef2621..dc67329 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt @@ -15,7 +15,6 @@ import com.tomasrepcik.sensorbox.domain.sensors.GetWearSensorsUseCase import com.tomasrepcik.sensorbox.domain.sensors.ObserveSensorValuesUseCase import com.tomasrepcik.sensorbox.domain.sync.SyncWearMeasurementsUseCase import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -71,6 +70,7 @@ class WearDashboardViewModel @Inject constructor( private fun handleDestination(destination: WearMenuDestination) { when (destination) { WearMenuDestination.PHONE_INFO -> mutableEffects.trySend(WearDashboardEffect.OpenPhone) + WearMenuDestination.SYNC -> startSync() WearMenuDestination.PRIVACY, WearMenuDestination.TERMS -> openUrl(destination) else -> Unit } @@ -142,11 +142,7 @@ class WearDashboardViewModel @Inject constructor( private fun observeSession() = viewModelScope.launch { sessionStore.state.collect { session -> - val route = if (session is MeasurementSessionState.Running) WearRoute.ACTIVE else null - if (route != null) mutableState.value = mutableState.value.copy(route = route) - if (session is MeasurementSessionState.Idle && mutableState.value.route == WearRoute.ACTIVE) { - mutableState.value = mutableState.value.copy(route = WearRoute.MENU) - } + mutableState.value = WearDashboardReducer.measurementSessionChanged(mutableState.value, session) } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt index 5acdd89..548e86c 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt @@ -6,6 +6,7 @@ import com.tomasrepcik.sensorbox.R enum class WearMenuDestination(@StringRes val labelResource: Int, @DrawableRes val iconResource: Int) { RECORD(R.string.activity_record, R.drawable.ic_record), + SYNC(R.string.sync_recordings, R.drawable.ic_sync), LIVE_SENSOR(R.string.activity_view_sensor, R.drawable.ic_live), PHONE_INFO(R.string.activity_info_phone, R.drawable.ic_phone), SETTINGS(R.string.activity_settings, R.drawable.ic_settings), diff --git a/wear/src/main/res/drawable/ic_sync.xml b/wear/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..22e4f50 --- /dev/null +++ b/wear/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml index 18d5ffb..e52be83 100644 --- a/wear/src/main/res/values/wear.xml +++ b/wear/src/main/res/values/wear.xml @@ -1,5 +1,6 @@ - + Date: Thu, 27 Aug 2026 22:10:06 +0200 Subject: [PATCH 4/4] test: cover recording, browsing, and Wear sync --- .../protocol/WearCommandCodecTest.kt | 75 +-- .../files/WearSyncEmulatorFixture.kt | 104 +++- .../PhoneSensorRecordingEmulatorTest.kt | 212 +++++--- .../PhoneWearDiscoveryEmulatorTest.kt | 175 +++++++ .../PhoneWearVisibilityEmulatorTest.kt | 30 ++ .../emulator/RecordingEmulatorFixture.kt | 452 ++++++++++++++++++ .../WearToPhoneSyncReceiverEmulatorTest.kt | 45 +- .../main/MeasurementBrowserScreenTest.kt | 136 ++++++ .../main/MeasurementSetupScreenTest.kt | 35 ++ .../presentation/main/RecordScreenRobot.kt | 13 + .../presentation/main/RecordScreenTest.kt | 15 + .../main/SensorDetailsScreenTest.kt | 70 +++ .../paired/PairedRecordingCoordinatorTest.kt | 318 ++++++------ .../paired/PhoneWearMessageDispatcherTest.kt | 51 -- .../sensors/WearSensorCatalogStoreTest.kt | 25 + .../presentation/main/FeatureViewModelTest.kt | 174 ++++++- .../main/MeasurementBrowserReducerTest.kt | 51 ++ .../main/MeasurementBrowserViewModelTest.kt | 107 +++++ .../presentation/main/RecordingReducerTest.kt | 14 + .../core/error/FileDiagnosticsTest.kt | 2 +- .../testing/FakeAppPreferencesRepository.kt | 1 - .../recording/RecordingEngineTest.kt | 124 ++--- tools/emulator/run_phone_sensor_test.sh | 34 -- tools/emulator/run_wear_sync_test.sh | 89 +++- .../emulator/WearRecordingEmulatorFixture.kt | 316 ++++++++++++ .../WearSensorRecordingEmulatorTest.kt | 136 ++++++ .../WearToPhoneSyncSenderEmulatorTest.kt | 36 +- .../presentation/menu/WearMenuRobot.kt | 8 + .../presentation/menu/WearMenuScreenTest.kt | 13 + .../dashboard/ReadmeWearScreenshotPreviews.kt | 23 +- .../communication/WearCommandHandlerTest.kt | 153 ++++-- .../WearMeasurementPermissionUseCaseTest.kt | 14 +- .../dashboard/WearDashboardReducerTest.kt | 24 + .../phonelaunch/PhoneLaunchViewModelTest.kt | 3 +- 34 files changed, 2563 insertions(+), 515 deletions(-) create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt delete mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStoreTest.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserReducerTest.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt delete mode 100755 tools/emulator/run_phone_sensor_test.sh create mode 100644 wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt create mode 100644 wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt index d0d0ebf..496b7dc 100644 --- a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class WearCommandCodecTest { @Test - fun `Given protocol v3 commands When round tripped Then every field survives`() { + fun `Given protocol v5 commands When round tripped Then every field survives`() { val request = WearRecordingRequest( folderName = "shared_session", sensorIds = listOf(1, 4, 21), @@ -17,22 +17,40 @@ class WearCommandCodecTest { val commands = listOf( WearCommand.LaunchPhone, WearCommand.SyncMeasurements, - WearCommand.RequestSensorList, - WearCommand.SensorList(listOf(WearSensorInfo(1, "Accelerometer", "Fixture"))), - WearCommand.PrepareRecording("session-123", request), - WearCommand.CommitRecording("session-123", 1_800_000_000_000L), - WearCommand.AbortRecording("session-123"), + WearCommand.RequestAvailableSensors, + WearCommand.AvailableSensors( + listOf( + WearSensorInfo( + type = 1, + name = "Accelerometer", + vendor = "Fixture", + version = 7, + stringType = "android.sensor.accelerometer", + maximumRange = 78.4f, + resolution = 0.0024f, + power = 0.25f, + minimumDelayMicros = 5_000, + maximumDelayMicros = 200_000, + reportingMode = 0, + isWakeUpSensor = true, + ), + ), + ), + WearCommand.StartRecording("session-123", request), WearCommand.StopRecording("session-123", WearStopReason.LOW_BATTERY), - WearCommand.Acknowledgement( + WearCommand.RecordingResult( sessionId = "session-123", - command = WearSessionCommand.PREPARE, - outcome = WearAcknowledgementOutcome.SUCCEEDED, + action = WearRecordingAction.START, + outcome = WearRecordingOutcome.SUCCEEDED, ), - WearCommand.Acknowledgement( + WearCommand.RecordingResult( sessionId = "session-123", - command = WearSessionCommand.STOP, - outcome = WearAcknowledgementOutcome.FAILED, + action = WearRecordingAction.STOP, + outcome = WearRecordingOutcome.FAILED, errorCode = AppErrorCode.MEASUREMENT, + errorOperation = "Stop Wear recording", + errorMessage = "Wear recording service could not stop", + errorContext = mapOf("serviceState" to "stopping"), failureCount = 2, ), ) @@ -43,20 +61,6 @@ class WearCommandCodecTest { } } - @Test - fun `Given a protocol v1 header When decoded Then it is rejected`() { - val v1Payload = byteArrayOf(0x53, 0x42, 0x58, 0x31, 0x01, 0x01) - - assertTrue(WearCommandCodec.decode(v1Payload).isFailure) - } - - @Test - fun `Given a protocol v2 header When decoded Then it is rejected`() { - val v2Payload = byteArrayOf(0x53, 0x42, 0x58, 0x32, 0x02, 0x01) - - assertTrue(WearCommandCodec.decode(v2Payload).isFailure) - } - @Test fun `Given trailing bytes When decoded Then payload is rejected`() { val valid = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() @@ -65,19 +69,21 @@ class WearCommandCodecTest { } @Test - fun `Given a truncated v3 payload When decoded Then it is rejected`() { - val malformed = byteArrayOf(0x53, 0x42, 0x58, 0x33, 0x03) + fun `Given a truncated current payload When decoded Then it is rejected`() { + val malformed = byteArrayOf(0x53, 0x42, 0x58, 0x34, 0x04) assertTrue(WearCommandCodec.decode(malformed).isFailure) } @Test - fun `Given a successful acknowledgement with an error When encoded Then it is rejected`() { - val invalid = WearCommand.Acknowledgement( + fun `Given a successful result with an error When encoded Then it is rejected`() { + val invalid = WearCommand.RecordingResult( sessionId = "session-123", - command = WearSessionCommand.COMMIT, - outcome = WearAcknowledgementOutcome.SUCCEEDED, + action = WearRecordingAction.START, + outcome = WearRecordingOutcome.SUCCEEDED, errorCode = AppErrorCode.MEASUREMENT, + errorOperation = "Unexpected failure", + errorMessage = "A successful result cannot contain an error", ) assertTrue(WearCommandCodec.encode(invalid).isFailure) @@ -85,7 +91,10 @@ class WearCommandCodecTest { @Test fun `Given a malformed session identifier When encoded Then it is rejected`() { - val invalid = WearCommand.AbortRecording("session/with/private/path") + val invalid = WearCommand.StopRecording( + "session/with/private/path", + WearStopReason.USER_REQUEST, + ) assertTrue(WearCommandCodec.encode(invalid).isFailure) } diff --git a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt index dc08bde..98bcfbc 100644 --- a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt +++ b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt @@ -1,11 +1,103 @@ package com.tomasrepcik.sensorbox.wearoslib.files +data class WearSyncFileFixture( + val measurementName: String, + val fileName: String, + val content: ByteArray, + val shouldTransfer: Boolean = true, + val existingPhoneContent: ByteArray? = null, +) { + val receivedMeasurementName: String = "WEAR_$measurementName" +} + +data class WearSyncScenario( + val id: String, + val files: List, + val emptyMeasurementNames: List = emptyList(), +) { + val transferredFiles: List = files.filter(WearSyncFileFixture::shouldTransfer) +} + object WearSyncEmulatorFixture { const val APP_DIRECTORY = "SensorBox" - const val MEASUREMENT_NAME = "EMULATOR_SYNC_TEST" - const val RECEIVED_MEASUREMENT_NAME = "WEAR_$MEASUREMENT_NAME" - const val FILE_NAME = "accelerometer.csv" - const val CONTENT = - "t_sensor;t_unix;x;y;z;accuracy\n" + - "123;456;1.0;2.0;3.0;3\n" + const val SCENARIO_ARGUMENT = "syncScenario" + + val scenarioIds: List get() = scenarios.map(WearSyncScenario::id) + + fun scenario(id: String): WearSyncScenario = scenarios.singleOrNull { it.id == id } + ?: error("Unknown sync scenario '$id'. Available: ${scenarioIds.joinToString()}") + + private val scenarios = listOf( + WearSyncScenario( + id = "single_csv", + files = listOf( + textFile( + measurement = "EMULATOR_SINGLE_CSV", + name = "accelerometer.csv", + text = "t_sensor;t_unix;x;y;z;accuracy\n123;456;1.0;2.0;3.0;3\n", + ), + ), + ), + WearSyncScenario( + id = "mixed_formats", + files = listOf( + textFile("MORNING WALK", "accelerometer.csv", "time;x;y;z\n1;0.1;0.2;0.3\n"), + textFile("MORNING WALK", "summary.json", "{\"activity\":\"chôdza 🚶\",\"samples\":1}"), + textFile("EVENING REST", "notes.txt", "Pokojné meranie — bez pohybu.\n"), + ), + ), + WearSyncScenario( + id = "empty_and_ignored", + files = listOf( + textFile("EMPTY DATA", "empty.txt", ""), + WearSyncFileFixture( + measurementName = "EMPTY DATA", + fileName = "raw.bin", + content = byteArrayOf(0x00, 0x01, 0x7F), + shouldTransfer = false, + ), + ), + emptyMeasurementNames = listOf("EMPTY DIRECTORY"), + ), + WearSyncScenario( + id = "overwrite_existing", + files = listOf( + textFile( + measurement = "REPEATED SYNC", + name = "location.csv", + text = "time;latitude;longitude\n2;48.1486;17.1077\n", + existingPhoneText = "stale phone content", + ), + ), + ), + WearSyncScenario( + id = "same_name_different_sessions", + files = listOf( + textFile("SESSION ONE", "accelerometer.csv", "session;value\none;1\n"), + textFile("SESSION TWO", "accelerometer.csv", "session;value\ntwo;2\n"), + ), + ), + WearSyncScenario( + id = "large_payload", + files = listOf( + WearSyncFileFixture( + measurementName = "LONG RECORDING", + fileName = "sensor.txt", + content = ByteArray(256 * 1024) { index -> (index % 251).toByte() }, + ), + ), + ), + ) + + private fun textFile( + measurement: String, + name: String, + text: String, + existingPhoneText: String? = null, + ) = WearSyncFileFixture( + measurementName = measurement, + fileName = name, + content = text.encodeToByteArray(), + existingPhoneContent = existingPhoneText?.encodeToByteArray(), + ) } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt index e2ae015..1e2c19c 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt @@ -1,92 +1,190 @@ package com.tomasrepcik.sensorbox.emulator import android.content.Context -import android.content.Intent import android.hardware.Sensor import android.hardware.SensorManager -import android.util.Log -import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.tomasrepcik.sensorbox.core.time.SystemEpochClock -import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest -import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import java.io.File @RunWith(AndroidJUnit4::class) class PhoneSensorRecordingEmulatorTest { private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - private val measurementDirectory = File(context.filesDir, "SensorBox/$MEASUREMENT_NAME") + private val fixture = RecordingEmulatorFixture(context) @Before - fun clearPreviousMeasurement() { - measurementDirectory.deleteRecursively() - } + fun prepareDevice() = fixture.prepareDevice() @After - fun stopMeasurement() { - context.startService(stopIntent()) + fun cleanUpDevice() = fixture.cleanUpDevice() + + @Test + fun givenAWorkoutWithAnAnnotationWhenStoppedThenCsvAndSessionMetadataAreComplete() { + fixture.start( + RecordingScenario( + name = "PHONE_ANNOTATED_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + notes = listOf("Outdoor workout", "Phone in jacket pocket"), + ), + ) + .waitFor(1_000) + .annotate("Reached the first checkpoint") + .waitFor(1_000) + .stop() + .assertRecorded() + .assertAnnotation("Reached the first checkpoint") } @Test - fun givenVirtualAccelerometerWhenValuesChangeThenCsvContainsSamples() { - assertTrue(hasAccelerometer()) - ContextCompat.startForegroundService(context, recordingIntent()) - Log.i(LOG_TAG, "READY_FOR_SENSOR_INJECTION") + fun givenTwoMotionSensorsAtGameSpeedWhenStoppedThenBothCsvFilesContainValidSamples() { + fixture.start( + RecordingScenario( + name = "PHONE_MULTI_SENSOR_RECORDING_TEST", + sensors = setOf(ACCELEROMETER, GYROSCOPE), + samplingPeriod = SensorManager.SENSOR_DELAY_GAME, + useWakeLock = true, + notes = listOf("Short movement drill"), + ), + ) + .waitFor(2_000) + .stop() + .assertRecorded() + } - Thread.sleep(RECORDING_WINDOW_MILLIS) - context.startService(stopIntent()) + @Test + fun givenATimedRecordingWhenDurationExpiresThenSamplesAreFinalized() { + fixture.start( + RecordingScenario( + name = "PHONE_TIMED_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST, + durationMillis = 4_000, + notes = listOf("Hands-free timed capture"), + ), + ) + .awaitAutomaticStop() + .assertRecorded() + } - val rows = awaitRecordedRows() - assertEquals(EXPECTED_HEADER, rows.first()) - assertTrue("Expected injected accelerometer samples, got $rows", rows.size >= MINIMUM_ROWS) + @Test + fun givenARecordingWhenStoppedDirectlyThenFilesCloseWithSamples() { + fixture.start( + RecordingScenario( + name = "PHONE_CANCELLED_DELAYED_RECORDING_TEST", + sensors = setOf(ACCELEROMETER, GYROSCOPE), + notes = listOf("Short direct capture"), + ), + ) + .waitFor(1_000) + .stop() + .assertRecorded() } - private fun hasAccelerometer(): Boolean = context.getSystemService(SensorManager::class.java) - .getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + @Test + fun givenGpsAndMotionWhenLocationsChangeThenBothSourceFilesAreComplete() { + fixture.start( + RecordingScenario( + name = "PHONE_GPS_RECORDING_TEST", + sessionId = "gps-session-from-phone", + sensors = setOf(ACCELEROMETER), + includesGps = true, + expectGpsSamples = true, + gpsIntervalSeconds = 1, + gpsMinDistanceMeters = 0, + notes = listOf("Walking route"), + ), + ) + .provideLocations( + TestLocation(48.1486, 17.1077), + TestLocation(48.1491, 17.1082), + TestLocation(48.1498, 17.1090), + ) + .stop() + .assertRecorded() + } - private fun recordingIntent(): Intent = MeasurementIntentFactory(context, SystemEpochClock).create( - MeasurementLaunchRequest( - folderName = MEASUREMENT_NAME, - useInternalStorage = true, - sensorIds = setOf(Sensor.TYPE_ACCELEROMETER), - sensorSamplingPeriod = SensorManager.SENSOR_DELAY_NORMAL, - includesGps = false, - stopOnLowBattery = false, - useWakeLock = false, - gpsIntervalSeconds = 10, - gpsMinDistanceMeters = 20, - ), - ) + @Test + fun givenActivityRecognitionAndDuplicateAlarmsWhenStoppedThenOptionsAreNormalizedAndSaved() { + fixture.start( + RecordingScenario( + name = "PHONE_ACTIVITY_AND_ALARM_TEST", + sensors = emptySet(), + alarmOffsetsSeconds = listOf(-3, 0, 0, 1), + activityRecognition = true, + activityRecognitionPeriodSeconds = 1, + notes = listOf("Activity-only capture"), + ), + ) + .waitFor(2_500) + .stop() + .assertRecorded() + .assertAlarmCount(2) + } - private fun stopIntent(): Intent = Intent(context, MeasurementService::class.java).apply { - action = MeasurementService.ACTION_STOP + @Test + fun givenLowBatteryProtectionWhenBatteryDropsThenTheRecordingFinalizes() { + fixture.start( + RecordingScenario( + name = "PHONE_LOW_BATTERY_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + samplingPeriod = SensorManager.SENSOR_DELAY_UI, + stopOnLowBattery = true, + notes = listOf("Battery-protected capture"), + ), + ) + .waitFor(1_000) + .dropBatteryTo(1) + .awaitAutomaticStop() + .assertRecorded() + } + + @Test + fun givenSignificantMotionWhenStoppedThenThePreparedFileCloses() { + fixture.start( + RecordingScenario( + name = "PHONE_SIGNIFICANT_MOTION_FAILURE_TEST", + sensors = emptySet(), + significantMotion = true, + notes = listOf("One-shot movement trigger"), + ), + ) + .waitFor(500) + .stop() + .assertRecorded() } - private fun awaitRecordedRows(): List { - val output = File(measurementDirectory, "accelerometer.csv") - repeat(FILE_WAIT_ATTEMPTS) { - val rows = output.takeIf(File::isFile)?.readLines().orEmpty() - if (rows.size >= MINIMUM_ROWS) return rows - Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) - } - return output.takeIf(File::isFile)?.readLines().orEmpty() + @Test + fun givenExternalStorageWithoutAPersistedDirectoryThenInternalFilesAreNotCreated() { + fixture.start( + RecordingScenario( + name = "PHONE_MISSING_EXTERNAL_STORAGE_TEST", + sensors = setOf(ACCELEROMETER), + useInternalStorage = false, + ), + ) + .assertStartRejected() + } + + @Test + fun givenANegativeDurationAndNoSourcesWhenStoppedThenASessionOnlyRecordingIsFinalized() { + fixture.start( + RecordingScenario( + name = "PHONE_SESSION_ONLY_RECORDING_TEST", + sensors = emptySet(), + durationMillis = -5_000, + notes = listOf("Metadata-only session"), + ), + ) + .waitFor(500) + .stop() + .assertRecorded() } private companion object { - const val LOG_TAG = "SensorBoxEmulatorTest" - const val MEASUREMENT_NAME = "PHONE_EMULATOR_SENSOR_TEST" - const val EXPECTED_HEADER = "t_sensor;t_unix;x;y;z;accuracy" - const val MINIMUM_ROWS = 2 - const val RECORDING_WINDOW_MILLIS = 8_000L - const val FILE_WAIT_ATTEMPTS = 20 - const val FILE_WAIT_INTERVAL_MILLIS = 250L + val ACCELEROMETER = RecordedSensor(Sensor.TYPE_ACCELEROMETER, "accelerometer.csv") + val GYROSCOPE = RecordedSensor(Sensor.TYPE_GYROSCOPE, "gyroscope.csv") } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt new file mode 100644 index 0000000..ef34dc3 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt @@ -0,0 +1,175 @@ +package com.tomasrepcik.sensorbox.emulator + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Wearable +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.connectivity.GooglePlayWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PhoneWearDiscoveryEmulatorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun givenPairedWearAppWhenPhoneRequestsSensorsThenWearReturnsItsCatalog() = runBlocking { + val response = CompletableDeferred() + val messageClient = Wearable.getMessageClient(context) + val listener = MessageClient.OnMessageReceivedListener { event -> + if (event.path == PHONE_MESSAGE_PATH) { + val command = WearCommandCodec.decode(event.data).getOrNull() + if (command is WearCommand.AvailableSensors) response.complete(command) + } + } + + Tasks.await(messageClient.addListener(listener)) + try { + val payload = WearCommandCodec.encode(WearCommand.RequestAvailableSensors).getOrThrow() + GooglePlayWearConnectionRepository(context) + .sendMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) + .getOrThrow() + + val catalog = withTimeout(RESPONSE_TIMEOUT_MILLIS) { response.await() } + assertTrue("Wear emulator returned no sensors", catalog.sensors.isNotEmpty()) + assertTrue( + "Wear emulator returned an incomplete sensor catalog", + catalog.sensors.all { sensor -> + sensor.stringType.isNotBlank() && + sensor.maximumRange.isFinite() && + sensor.resolution.isFinite() && + sensor.power.isFinite() + }, + ) + } finally { + Tasks.await(messageClient.removeListener(listener)) + } + } + + @Test + fun givenWearNotificationsDeniedWhenPhoneStartsSensorRecordingThenWearAcceptsIt() = runBlocking { + val commands = Channel(Channel.UNLIMITED) + val messageClient = Wearable.getMessageClient(context) + val listener = MessageClient.OnMessageReceivedListener { event -> + if (event.path == PHONE_MESSAGE_PATH) { + WearCommandCodec.decode(event.data).getOrNull()?.let(commands::trySend) + } + } + val connection = GooglePlayWearConnectionRepository(context) + val sessionId = "permission-free-sensor-session" + + Tasks.await(messageClient.addListener(listener)) + try { + connection.sendMessage( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommandCodec.encode(WearCommand.RequestAvailableSensors).getOrThrow(), + ).getOrThrow() + val catalog = awaitCommand(commands) + + val start = WearCommand.StartRecording( + sessionId = sessionId, + request = WearRecordingRequest( + folderName = "paired_sensor_permission_test", + sensorIds = listOf(catalog.sensors.first().type), + includesGps = false, + ), + ) + connection.sendMessage( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommandCodec.encode(start).getOrThrow(), + ).getOrThrow() + + val result = awaitCommand(commands) { + it.sessionId == sessionId && it.action == WearRecordingAction.START + } + assertEquals(WearRecordingOutcome.SUCCEEDED, result.outcome) + } finally { + connection.sendMessage( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommandCodec.encode( + WearCommand.StopRecording(sessionId, WearStopReason.USER_REQUEST), + ).getOrThrow(), + ) + Tasks.await(messageClient.removeListener(listener)) + } + } + + @Test + fun givenWearLocationDeniedWhenPhoneStartsGpsThenResultExplainsPermission() = runBlocking { + val commands = Channel(Channel.UNLIMITED) + val messageClient = Wearable.getMessageClient(context) + val listener = MessageClient.OnMessageReceivedListener { event -> + if (event.path == PHONE_MESSAGE_PATH) { + WearCommandCodec.decode(event.data).getOrNull()?.let(commands::trySend) + } + } + val connection = GooglePlayWearConnectionRepository(context) + val sessionId = "gps-permission-diagnostic-session" + + Tasks.await(messageClient.addListener(listener)) + try { + connection.sendMessage( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommandCodec.encode( + WearCommand.StartRecording( + sessionId = sessionId, + request = WearRecordingRequest( + folderName = "paired_gps_permission_test", + sensorIds = emptyList(), + includesGps = true, + ), + ), + ).getOrThrow(), + ).getOrThrow() + + val result = awaitCommand(commands) { + it.sessionId == sessionId && it.action == WearRecordingAction.START + } + assertEquals(WearRecordingOutcome.FAILED, result.outcome) + assertEquals("Validate Wear recording permissions", result.errorOperation) + assertEquals("Wear OS is missing required recording permissions", result.errorMessage) + assertTrue( + result.errorContext["missingPermissions"] + .orEmpty() + .contains("android.permission.ACCESS_FINE_LOCATION"), + ) + } finally { + Tasks.await(messageClient.removeListener(listener)) + } + } + + private suspend inline fun awaitCommand( + commands: Channel, + crossinline matches: (T) -> Boolean = { true }, + ): T = withTimeout(RESPONSE_TIMEOUT_MILLIS) { + commands.receiveAsFlow().filterIsInstance().first { matches(it) } + } + + private companion object { + const val RESPONSE_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt new file mode 100644 index 0000000..a8b1e79 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt @@ -0,0 +1,30 @@ +package com.tomasrepcik.sensorbox.emulator + +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.performScrollToNode +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.tomasrepcik.sensorbox.activities.MainActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PhoneWearVisibilityEmulatorTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun givenPairedWearAppWhenSourcesOpenThenWearSectionIsVisible() { + compose.waitUntil(RESPONSE_TIMEOUT_MILLIS) { + compose.onAllNodes(hasScrollAction()).fetchSemanticsNodes().isNotEmpty() + } + compose.onNode(hasScrollAction()).performScrollToNode(hasText("Wear OS")) + compose.onNode(hasText("Wear OS")).assertExists() + } + + private companion object { + const val RESPONSE_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt new file mode 100644 index 0000000..0269a55 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt @@ -0,0 +1,452 @@ +package com.tomasrepcik.sensorbox.emulator + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager +import android.os.ParcelFileDescriptor +import androidx.core.content.ContextCompat +import androidx.test.platform.app.InstrumentationRegistry +import com.tomasrepcik.sensorbox.core.time.SystemEpochClock +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementIntentFactory +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest +import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import java.io.File + +internal data class RecordedSensor( + val type: Int, + val fileName: String, + val header: String = "t_sensor;t_unix;x;y;z;accuracy", + val columnCount: Int = 6, +) + +internal data class RecordingScenario( + val name: String, + val sensors: Set, + val sessionId: String = "instrumentation-$name", + val useInternalStorage: Boolean = true, + val samplingPeriod: Int = SensorManager.SENSOR_DELAY_NORMAL, + val includesGps: Boolean = false, + val expectGpsSamples: Boolean = false, + val stopOnLowBattery: Boolean = false, + val useWakeLock: Boolean = false, + val gpsIntervalSeconds: Int = 10, + val gpsMinDistanceMeters: Int = 20, + val durationMillis: Long = 0L, + val notes: List = emptyList(), + val alarmOffsetsSeconds: List = emptyList(), + val activityRecognition: Boolean = false, + val activityRecognitionPeriodSeconds: Int = 30, + val significantMotion: Boolean = false, +) + +internal data class TestLocation( + val latitude: Double, + val longitude: Double, + val accuracyMeters: Int = 3, +) + +internal class RecordingEmulatorFixture(private val context: Context) { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val sensorManager = context.getSystemService(SensorManager::class.java) + private var mockGpsActive = false + + fun prepareDevice() { + assumeFalse( + "Phone recording scenarios do not run on Wear OS", + context.packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH), + ) + grantRuntimePermissions() + resetBattery() + } + + fun cleanUpDevice() { + stopAnyRecording() + resetBattery() + removeMockGps() + } + + fun start(scenario: RecordingScenario): ActiveRecording { + scenario.sensors.forEach { sensor -> + assertTrue( + "Emulator is missing sensor type ${sensor.type}", + sensorManager.getDefaultSensor(sensor.type) != null, + ) + } + measurementDirectory(scenario.name).deleteRecursively() + if (!scenario.useInternalStorage) clearDocumentStoragePermissions() + if (scenario.includesGps) prepareMockGps() + val scheduledStartMillis = SystemEpochClock.nowMillis() + val request = MeasurementLaunchRequest( + sessionId = scenario.sessionId, + folderName = scenario.name, + useInternalStorage = scenario.useInternalStorage, + sensorIds = scenario.sensors.mapTo(mutableSetOf(), RecordedSensor::type), + sensorSamplingPeriod = scenario.samplingPeriod, + includesGps = scenario.includesGps, + stopOnLowBattery = scenario.stopOnLowBattery, + useWakeLock = scenario.useWakeLock, + gpsIntervalSeconds = scenario.gpsIntervalSeconds, + gpsMinDistanceMeters = scenario.gpsMinDistanceMeters, + durationMillis = scenario.durationMillis, + notes = scenario.notes, + alarmOffsetsSeconds = scenario.alarmOffsetsSeconds, + activityRecognition = scenario.activityRecognition, + activityRecognitionPeriodSeconds = scenario.activityRecognitionPeriodSeconds, + significantMotion = scenario.significantMotion, + ) + val intent = MeasurementIntentFactory(context, SystemEpochClock).create(request) + assertIntentMatches(intent, request) + ContextCompat.startForegroundService(context, intent) + val expectedRecordedTypes = scenario.sensors.mapTo(mutableSetOf(), RecordedSensor::type).apply { + if ( + scenario.significantMotion && + sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) != null + ) { + add(Sensor.TYPE_SIGNIFICANT_MOTION) + } + } + return ActiveRecording(this, scenario, scheduledStartMillis, expectedRecordedTypes) + } + + private fun assertIntentMatches(intent: Intent, request: MeasurementLaunchRequest) { + assertEquals(request.sessionId, intent.getStringExtra(MeasurementService.SESSION_ID)) + assertEquals(request.folderName, intent.getStringExtra(MeasurementService.FOLDER_NAME)) + assertEquals(request.useInternalStorage, intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false)) + val sensorIds = intent.getIntArrayExtra(MeasurementService.ANDROID_SENSORS) ?: intArrayOf() + assertEquals(request.sensorIds, sensorIds.toSet()) + assertEquals( + request.sensorSamplingPeriod, + intent.getIntExtra(MeasurementService.ANDROID_SENSORS_SPEED, Int.MIN_VALUE), + ) + assertEquals(request.includesGps, intent.getBooleanExtra(MeasurementService.GPS, false)) + assertEquals(request.stopOnLowBattery, intent.getBooleanExtra(MeasurementService.STOP_ON_LOW_BATTERY, false)) + assertEquals(request.useWakeLock, intent.getBooleanExtra(MeasurementService.USE_WAKE_LOCK, false)) + assertEquals(request.gpsIntervalSeconds, intent.getIntExtra(MeasurementService.GPS_INTERVAL_SECONDS, -1)) + assertEquals(request.gpsMinDistanceMeters, intent.getIntExtra(MeasurementService.GPS_DISTANCE_METERS, -1)) + assertEquals(request.durationMillis, intent.getLongExtra(MeasurementService.DURATION_MILLIS, -1)) + assertEquals(request.notes, intent.getStringArrayListExtra(MeasurementService.NOTES).orEmpty()) + assertEquals( + request.alarmOffsetsSeconds, + (intent.getIntArrayExtra(MeasurementService.ALARM_OFFSETS_SECONDS) ?: intArrayOf()).toList(), + ) + assertEquals( + request.activityRecognition, + intent.getBooleanExtra(MeasurementService.ACTIVITY_RECOGNITION, false), + ) + assertEquals( + request.activityRecognitionPeriodSeconds, + intent.getIntExtra(MeasurementService.ACTIVITY_RECOGNITION_PERIOD_SECONDS, -1), + ) + assertEquals(request.significantMotion, intent.getBooleanExtra(MeasurementService.SIGNIFICANT_MOTION, false)) + } + + fun stopAnyRecording() { + context.startService(serviceIntent(MeasurementService.ACTION_STOP)) + } + + private fun grantRuntimePermissions() { + val requestedPermissions = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + .orEmpty() + .toSet() + RUNTIME_PERMISSIONS + .filter(requestedPermissions::contains) + .forEach { permission -> + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, permission) + } + } + + private fun prepareMockGps() { + shell("appops set 2000 android:mock_location allow") + shell( + "cmd location providers add-test-provider gps " + + "--requiresSatellite --supportsAltitude --supportsSpeed --supportsBearing", + ) + shell("cmd location providers set-test-provider-enabled gps true") + mockGpsActive = true + } + + private fun provideLocations(locations: List) { + Thread.sleep(LOCATION_STARTUP_MILLIS) + locations.forEach { location -> + shell( + "cmd location providers set-test-provider-location gps " + + "--location ${location.latitude},${location.longitude} " + + "--accuracy ${location.accuracyMeters} --time ${SystemEpochClock.nowMillis()}", + ) + Thread.sleep(LOCATION_UPDATE_MILLIS) + } + } + + private fun removeMockGps() { + if (!mockGpsActive) return + shell("cmd location providers remove-test-provider gps") + shell("appops set 2000 android:mock_location deny") + mockGpsActive = false + } + + private fun setBatteryLevel(level: Int) { + require(level in 0..100) { "Battery level must be between 0 and 100" } + shell("dumpsys battery unplug -f") + shell("dumpsys battery set -f level $level") + } + + private fun resetBattery() { + shell("dumpsys battery reset -f") + } + + private fun shell(command: String): String = + ParcelFileDescriptor.AutoCloseInputStream(instrumentation.uiAutomation.executeShellCommand(command)) + .bufferedReader() + .use { it.readText() } + + private fun annotate(text: String) { + context.startService(serviceIntent(MeasurementService.ACTION_ANNOTATE).apply { + putExtra(MeasurementService.ANNOTATION_TIME, SystemEpochClock.nowMillis()) + putExtra(MeasurementService.ANNOTATION_TEXT, text) + }) + } + + private fun clearDocumentStoragePermissions() { + context.contentResolver.persistedUriPermissions.forEach { permission -> + var flags = 0 + if (permission.isReadPermission) flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION + if (permission.isWritePermission) flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.releasePersistableUriPermission(permission.uri, flags) + } + } + + private fun complete( + scenario: RecordingScenario, + scheduledStartMillis: Long, + expectedRecordedTypes: Set, + ): RecordingOutput { + val metadata = awaitMetadata(scenario.name) + return RecordingOutput( + scenario = scenario, + scheduledStartMillis = scheduledStartMillis, + expectedRecordedTypes = expectedRecordedTypes, + metadata = metadata, + files = measurementDirectory(scenario.name).listFiles().orEmpty().associateBy(File::getName), + ) + } + + private fun rows(scenario: RecordingScenario, sensor: RecordedSensor): List { + val output = File(measurementDirectory(scenario.name), sensor.fileName) + repeat(FILE_WAIT_ATTEMPTS) { + val rows = output.takeIf(File::isFile)?.readLines().orEmpty() + if (rows.isNotEmpty()) return rows + Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) + } + return output.takeIf(File::isFile)?.readLines().orEmpty() + } + + private fun awaitMetadata(measurementName: String): JSONObject { + val output = File(measurementDirectory(measurementName), METADATA_FILE) + repeat(FILE_WAIT_ATTEMPTS) { + output.takeIf { it.isFile && it.length() > 0L }?.let { return JSONObject(it.readText()) } + Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) + } + assertTrue("Recording did not finalize $output", output.isFile) + return JSONObject(output.readText()) + } + + private fun measurementDirectory(measurementName: String): File = + File(context.filesDir, "SensorBox/$measurementName") + + private fun serviceIntent(actionName: String): Intent = Intent(context, MeasurementService::class.java).apply { + action = actionName + } + + internal class ActiveRecording( + private val fixture: RecordingEmulatorFixture, + private val scenario: RecordingScenario, + private val scheduledStartMillis: Long, + private val expectedRecordedTypes: Set, + ) { + fun waitFor(delayMillis: Long): ActiveRecording = apply { + Thread.sleep(delayMillis) + } + + fun provideLocations(vararg locations: TestLocation): ActiveRecording = apply { + fixture.provideLocations(locations.toList()) + } + + fun dropBatteryTo(level: Int): ActiveRecording = apply { + fixture.setBatteryLevel(level) + } + + fun annotate(text: String): ActiveRecording = apply { + fixture.annotate(text) + } + + fun assertOnlyHeaders(): ActiveRecording = apply { + scenario.sensors.forEach { sensor -> + assertEquals(listOf(sensor.header), fixture.rows(scenario, sensor)) + } + } + + fun stop(): RecordingOutput { + fixture.stopAnyRecording() + return fixture.complete(scenario, scheduledStartMillis, expectedRecordedTypes) + } + + fun awaitAutomaticStop(): RecordingOutput = + fixture.complete(scenario, scheduledStartMillis, expectedRecordedTypes) + + fun assertStartRejected(): ActiveRecording = apply { + Thread.sleep(STORAGE_FAILURE_WAIT_MILLIS) + assertFalse(fixture.measurementDirectory(scenario.name).exists()) + } + } + + internal class RecordingOutput( + private val scenario: RecordingScenario, + private val scheduledStartMillis: Long, + private val expectedRecordedTypes: Set, + private val metadata: JSONObject, + private val files: Map, + ) { + fun assertRecorded(): RecordingOutput = apply { + assertSessionMetadata() + assertExactFiles() + scenario.sensors.forEach { sensor -> assertValidCsv(files.getValue(sensor.fileName), sensor) } + if (scenario.includesGps) assertGpsCsv(files.getValue(GPS_FILE), scenario.expectGpsSamples) + if (scenario.activityRecognition) assertActivityFiles() + if (scenario.significantMotion) assertHeader(files.getValue(SIGNIFICANT_MOTION_FILE), SIGNIFICANT_HEADER) + } + + fun assertCancelledBeforeStart(): RecordingOutput = apply { + assertSessionMetadata() + assertExactFiles() + scenario.sensors.forEach { sensor -> + assertEquals(listOf(sensor.header), files.getValue(sensor.fileName).readLines()) + } + } + + fun assertAnnotation(expected: String): RecordingOutput = apply { + val annotations = metadata.getJSONArray("annotations") + assertEquals(1, annotations.length()) + assertEquals(expected, annotations.getJSONObject(0).getString("annotation")) + } + + fun assertAlarmCount(expected: Int): RecordingOutput = apply { + assertEquals(expected, metadata.getJSONArray("alarms").length()) + } + + private fun assertSessionMetadata() { + assertEquals(scenario.name, metadata.getString("folder")) + assertEquals(scenario.notes, metadata.getJSONArray("notes").toStringList()) + assertEquals(scenario.durationMillis.coerceAtLeast(0), metadata.getLong("durationMillis")) + assertEquals( + scenario.alarmOffsetsSeconds.filter { it >= 0 }, + metadata.getJSONArray("configuredAlarmOffsetsSeconds").toIntList(), + ) + assertEquals(scenario.activityRecognition, metadata.getBoolean("activityRecognition")) + assertEquals(scenario.significantMotion, metadata.getBoolean("significantMotion")) + val recordedTypes = metadata.getJSONArray("ranges").let { ranges -> + buildSet { + repeat(ranges.length()) { add(ranges.getJSONObject(it).getInt("type")) } + } + } + assertEquals(expectedRecordedTypes, recordedTypes) + } + + private fun assertExactFiles() { + val expected = scenario.sensors.mapTo(mutableSetOf(), RecordedSensor::fileName).apply { + add(METADATA_FILE) + if (scenario.includesGps) add(GPS_FILE) + if (scenario.activityRecognition) { + add(ACTIVITY_UPDATES_FILE) + add(ACTIVITY_TRANSITIONS_FILE) + } + if (scenario.significantMotion) add(SIGNIFICANT_MOTION_FILE) + } + assertEquals(expected, files.keys) + } + + private fun assertGpsCsv(file: File, expectSamples: Boolean) { + val rows = file.readLines() + assertEquals(GPS_HEADER, rows.first()) + if (!expectSamples) return + assertTrue("Expected injected GPS samples in $file, got $rows", rows.size >= MINIMUM_ROWS) + rows.drop(1).forEach { row -> + val columns = row.split(';') + assertEquals("Malformed GPS row: $row", GPS_COLUMN_COUNT, columns.size) + assertTrue("Malformed GPS timestamp: $row", columns[0].toLongOrNull() != null) + assertTrue("Malformed GPS coordinates: $row", columns.slice(1..6).all { it.toDoubleOrNull() != null }) + assertTrue("Missing GPS provider: $row", columns[7].isNotBlank()) + } + } + + private fun assertActivityFiles() { + assertHeader(files.getValue(ACTIVITY_UPDATES_FILE), ACTIVITY_UPDATES_HEADER) + assertHeader(files.getValue(ACTIVITY_TRANSITIONS_FILE), ACTIVITY_TRANSITIONS_HEADER) + } + + private fun assertHeader(file: File, expected: String) { + assertEquals(expected, file.readLines().first()) + } + + private fun assertValidCsv(file: File, sensor: RecordedSensor) { + val rows = file.readLines() + assertTrue("Expected a header and recorded samples in $file, got $rows", rows.size >= MINIMUM_ROWS) + assertEquals(sensor.header, rows.first()) + val samples = rows.drop(1).map { row -> + val columns = row.split(';') + assertEquals("Malformed CSV row: $row", sensor.columnCount, columns.size) + assertTrue("Non-numeric CSV row: $row", columns.all { it.toDoubleOrNull() != null }) + columns + } + val sensorTimestamps = samples.map { it[0].toLong() } + val unixTimestamps = samples.map { it[1].toLong() } + assertEquals(sensorTimestamps.sorted(), sensorTimestamps) + assertEquals(unixTimestamps.sorted(), unixTimestamps) + assertTrue( + "A sample was written before recording started: $unixTimestamps", + unixTimestamps.all { it >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS }, + ) + } + } + + private companion object { + val RUNTIME_PERMISSIONS = listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACTIVITY_RECOGNITION, + Manifest.permission.POST_NOTIFICATIONS, + ) + const val METADATA_FILE = "extra.json" + const val GPS_FILE = "gps.csv" + const val GPS_HEADER = "time_millis;latitude;longitude;altitude;accuracy;speed;bearing;provider" + const val GPS_COLUMN_COUNT = 8 + const val ACTIVITY_UPDATES_FILE = "activity_updates.csv" + const val ACTIVITY_UPDATES_HEADER = "t_elapsed;still;on_foot;walking;running;vehicle;bike;unknown;tilting" + const val ACTIVITY_TRANSITIONS_FILE = "activity_transitions.csv" + const val ACTIVITY_TRANSITIONS_HEADER = "t_nanos;activity;enter_exit" + const val SIGNIFICANT_MOTION_FILE = "significant_motion.csv" + const val SIGNIFICANT_HEADER = "t_unix;event" + const val MINIMUM_ROWS = 2 + const val TIMESTAMP_TOLERANCE_MILLIS = 250L + const val FILE_WAIT_ATTEMPTS = 40 + const val FILE_WAIT_INTERVAL_MILLIS = 250L + const val STORAGE_FAILURE_WAIT_MILLIS = 1_000L + const val LOCATION_STARTUP_MILLIS = 1_000L + const val LOCATION_UPDATE_MILLIS = 1_100L + } +} + +private fun org.json.JSONArray.toStringList(): List = + List(length()) { index -> getString(index) } + +private fun org.json.JSONArray.toIntList(): List = + List(length()) { index -> getInt(index) } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt index dfbace7..334d158 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt @@ -4,7 +4,9 @@ import android.content.Context import android.util.Log import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncEmulatorFixture +import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncFileFixture import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -15,33 +17,54 @@ import java.io.File @RunWith(AndroidJUnit4::class) class WearToPhoneSyncReceiverEmulatorTest { private val context: Context = ApplicationProvider.getApplicationContext() - private val receivedFile = File( - context.filesDir, - "${WearSyncEmulatorFixture.APP_DIRECTORY}/" + - "${WearSyncEmulatorFixture.RECEIVED_MEASUREMENT_NAME}/${WearSyncEmulatorFixture.FILE_NAME}", + private val scenario = WearSyncEmulatorFixture.scenario( + requireNotNull( + InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT), + ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" }, ) + private val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY) @Before - fun removePreviousTransfer() { - receivedFile.parentFile?.deleteRecursively() + fun preparePhoneDestination() { + root.deleteRecursively() + scenario.transferredFiles.forEach { fixture -> + fixture.existingPhoneContent?.let { existingContent -> + receivedFile(fixture).apply { + parentFile?.mkdirs() + writeBytes(existingContent) + } + } + } } @Test - fun givenPairedEmulatorsWhenWearSendsCsvThenPhoneReceivesExactBytes() { + fun givenPairedEmulatorsWhenWearSendsFilesThenPhoneReceivesExactBytes() { Log.i(LOG_TAG, "READY_FOR_WEAR_TRANSFER") - assertTrue("Timed out waiting for $receivedFile", waitForReceivedFile()) - assertArrayEquals(WearSyncEmulatorFixture.CONTENT.encodeToByteArray(), receivedFile.readBytes()) + scenario.transferredFiles.forEach { fixture -> + val received = receivedFile(fixture) + assertTrue("Timed out waiting for $received", waitFor(received, fixture.content)) + assertArrayEquals("Wrong bytes in $received", fixture.content, received.readBytes()) + } + scenario.files.filterNot(WearSyncFileFixture::shouldTransfer).forEach { fixture -> + assertTrue("Unsupported file was transferred: ${receivedFile(fixture)}", !receivedFile(fixture).exists()) + } } - private fun waitForReceivedFile(): Boolean { + private fun receivedFile(fixture: WearSyncFileFixture) = + File(root, "${fixture.receivedMeasurementName}/${fixture.fileName}") + + private fun waitFor(receivedFile: File, expectedContent: ByteArray): Boolean { repeat(MAX_ATTEMPTS) { - if (receivedFile.isFile && receivedFile.length() > 0L) return true + if (receivedFile.isFile && receivedFile.contentEquals(expectedContent)) return true Thread.sleep(POLL_INTERVAL_MILLIS) } return false } + private fun File.contentEquals(expectedContent: ByteArray): Boolean = + length() == expectedContent.size.toLong() && readBytes().contentEquals(expectedContent) + private companion object { const val LOG_TAG = "SensorBoxEmulatorTest" const val POLL_INTERVAL_MILLIS = 500L diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt new file mode 100644 index 0000000..7de1aa4 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt @@ -0,0 +1,136 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary +import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class MeasurementBrowserScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenArchiveWhenMeasurementIsTappedThenItsIdentifierIsSelected() { + var selectedId: String? = null + composeRule.setContent { + SensorBoxTheme { + MeasurementsScreen( + state = MeasurementBrowserState(measurements = listOf(summary)), + onIntent = { intent -> + if (intent is MeasurementBrowserIntent.OpenMeasurementDetails) { + selectedId = intent.measurementId + } + }, + onBack = {}, + ) + } + } + + composeRule.onNodeWithText("Morning walk").assertIsDisplayed().performClick() + composeRule.runOnIdle { assertEquals(summary.id, selectedId) } + } + + @Test + fun givenMeasurementDetailsThenAllMetadataAndFilesCanBeInspected() { + var selectedFile: String? = null + composeRule.setContent { + SensorBoxTheme { + MeasurementDetailsScreen( + state = MeasurementBrowserState(selectedMeasurement = details), + onIntent = { intent -> + if (intent is MeasurementBrowserIntent.OpenMeasurementFile) selectedFile = intent.fileId + }, + onBack = {}, + ) + } + } + + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Pixel fixture")) + composeRule.onNodeWithText("Pixel fixture").assertIsDisplayed() + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Accelerometer")) + composeRule.onNodeWithText("Accelerometer").assertIsDisplayed().performClick() + composeRule.runOnIdle { assertEquals(sensorFile.id, selectedFile) } + } + + @Test + fun givenSensorSamplesThenTheirSeriesAndChartSummaryAreShown() { + var chartIntent: MeasurementBrowserIntent? = null + showFile( + sensorFile, + MeasurementFileContent.SensorSeries( + columns = listOf("x", "y", "z"), + samples = listOf( + SensorSeriesSample(1, listOf(0.1, 0.2, 0.3)), + SensorSeriesSample(2, listOf(0.4, 0.5, 0.6)), + ), + truncated = false, + ), + onIntent = { chartIntent = it }, + ) + + composeRule.onNodeWithText("2 chart samples").assertIsDisplayed() + composeRule.onNodeWithText("x, y, z").assertIsDisplayed() + composeRule.onNodeWithText("Zoom out").assertIsDisplayed() + composeRule.onNodeWithText("Show all").assertIsDisplayed() + composeRule.onNodeWithText("Zoom in").assertIsDisplayed().performClick() + composeRule.runOnIdle { + assertEquals(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(2f, 0.5f), chartIntent) + } + } + + @Test + fun givenGpsSamplesThenCoordinatesKeepNavigationPrecision() { + showFile( + gpsFile, + MeasurementFileContent.GpsCoordinates( + listOf(GpsCoordinate(1_725_000_000_000, 48.148596, 17.107748, 140.0, 3.0, null, null, "gps")), + truncated = false, + ), + ) + + composeRule.onNodeWithText("Coordinate 1").assertIsDisplayed() + composeRule.onNodeWithText("48.148596 , 17.107748").assertIsDisplayed() + } + + private fun showFile( + file: MeasurementFileSummary, + content: MeasurementFileContent, + onIntent: (MeasurementBrowserIntent) -> Unit = {}, + ) { + composeRule.setContent { + SensorBoxTheme { + MeasurementFileScreen( + state = MeasurementBrowserState(selectedFile = file, selectedFileContent = content), + onIntent = onIntent, + onBack = {}, + ) + } + } + } + + private companion object { + val summary = MeasurementSummary("session-1", "Morning walk", 1_725_000_000_000, "2026-08-25", 2) + val sensorFile = MeasurementFileSummary("accelerometer.csv", "Accelerometer", MeasurementFileKind.SENSOR, 256) + val gpsFile = MeasurementFileSummary("gps.csv", "Gps", MeasurementFileKind.GPS, 128) + val details = MeasurementDetails( + summary, + listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")), + listOf(sensorFile, gpsFile), + ) + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreenTest.kt index aedcce7..0ad3245 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementSetupScreenTest.kt @@ -1,6 +1,9 @@ package com.tomasrepcik.sensorbox.presentation.main +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -21,4 +24,36 @@ class MeasurementSetupScreenTest { assertEquals(RecordingIntent.StartMeasurement, actualIntent) } } + + @Test + fun givenWearPermissionFailureThenActionableWatchGuidanceIsDisplayed() { + composeRule.setContent { + SensorBoxTheme { + RecordingMessageText(RecordingMessage.WEAR_PERMISSION_REQUIRED) + } + } + + composeRule.onNodeWithText("Permission required on Wear OS").assertIsDisplayed() + composeRule.onNodeWithText( + "Open SensorBox on your watch, grant Location permission, then try again.", + ).assertIsDisplayed() + } + + @Test + fun givenRecordingIsStartingThenProgressLabelIsDisplayed() { + composeRule.setContent { + SensorBoxTheme { + MeasurementSetupScreen( + state = RecordingState( + selectedSensorIds = setOf(1), + storagePath = "Fixture/SensorBox", + isStarting = true, + ), + onIntent = { }, + ) + } + } + + composeRule.onNodeWithText("Starting recording…").assertIsDisplayed() + } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt index 9d9939f..35162a9 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme @@ -12,6 +13,7 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { fun givenRecordScreen( selected: Boolean = false, gpsSelected: Boolean = false, + wearConnected: Boolean = false, onIntent: (RecordingIntent) -> Unit = {}, ) = apply { rule.setContent { @@ -21,6 +23,12 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture")), selectedSensorIds = if (selected) setOf(1) else emptySet(), includesGps = gpsSelected, + isWearConnected = wearConnected, + wearSensors = if (wearConnected) { + listOf(SensorDescriptor(1, "Wear Accelerometer", "Fixture")) + } else { + emptyList() + }, storagePath = "Fixture/SensorBox", ), onIntent = onIntent, @@ -41,6 +49,11 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { rule.onNodeWithText("GPS").performClick() } + fun whenWearAccelerometerInfoIsTapped() = apply { + rule.onNodeWithText("Wear Accelerometer").performScrollTo() + rule.onNodeWithContentDescription("Information about Wear Accelerometer").performClick() + } + fun whenContinueIsTapped() = apply { rule.onNodeWithText("Continue").performClick() } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt index 0be5da2..4af5b7b 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt @@ -58,6 +58,21 @@ class RecordScreenTest { } } + @Test + fun givenWearSensorWhenInfoIsTappedThenWearDetailsIntentIsSent() { + var actualIntent: RecordingIntent? = null + RecordScreenRobot(composeRule) + .givenRecordScreen(wearConnected = true) { actualIntent = it } + .whenWearAccelerometerInfoIsTapped() + + composeRule.runOnIdle { + assertEquals( + RecordingIntent.OpenSensorDetails(1, RecordingSensorSource.WEAR), + actualIntent, + ) + } + } + @Test fun givenOnlyGpsSelectedWhenContinueIsTappedThenSetupIntentIsSent() { var actualIntent: RecordingIntent? = null diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt new file mode 100644 index 0000000..39e05cd --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt @@ -0,0 +1,70 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode +import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import org.junit.Rule +import org.junit.Test + +class SensorDetailsScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenWearSensorThenStaticParametersAreShownWithoutLivePreview() { + composeRule.setContent { + SensorBoxTheme { + SensorDetailsScreen( + state = RecordingState( + wearSensors = listOf( + SensorDescriptor( + type = 1, + name = "Wear Accelerometer", + vendor = "Fixture Wear Vendor", + version = 7, + stringType = "fixture.sensor.accelerometer", + maximumRange = 78.4f, + resolution = 0.0024f, + power = 0.25f, + minimumDelayMicros = 5_000, + maximumDelayMicros = 200_000, + reportingMode = SensorReportingMode.CONTINUOUS, + isWakeUpSensor = true, + ), + ), + detailsSensorType = 1, + detailsSensorSource = RecordingSensorSource.WEAR, + ), + onBack = {}, + onPreview = {}, + ) + } + } + + composeRule.onNodeWithText("Wear Accelerometer").assertIsDisplayed() + composeRule.onNodeWithText("Fixture Wear Vendor").assertIsDisplayed() + listOf( + "Version", + "Resolution", + "Power", + "Maximum range", + "Minimum delay", + "Maximum delay", + "Android sensor type", + "Reporting mode", + "Wake-up sensor", + ).forEach { label -> + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText(label, substring = true)) + composeRule.onNodeWithText(label, substring = true).assertIsDisplayed() + } + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("fixture.sensor.accelerometer")) + composeRule.onNodeWithText("fixture.sensor.accelerometer").assertIsDisplayed() + composeRule.onNodeWithText("Preview").assertDoesNotExist() + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt index de257d8..484872a 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt @@ -4,22 +4,19 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.domain.measurement.MeasurementRequest import com.tomasrepcik.sensorbox.domain.measurement.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.measurement.PreparedPhoneRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest +import com.tomasrepcik.sensorbox.domain.measurement.StartedPhoneRecording import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnectionRepository import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearAcknowledgementOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSessionCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingAction +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest @@ -27,199 +24,244 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class PairedRecordingCoordinatorTest { @Test - fun `Given Wear rejects prepare When start is requested Then local preparation is aborted`() = runTest { + fun `Given phone sources only When recording starts Then no Wear command is sent`() = runTest { + // Given val fixture = Fixture() - fixture.repository.onCommand = { command -> - when (command) { - is WearCommand.PrepareRecording -> fixture.ack( - command.sessionId, - WearSessionCommand.PREPARE, - WearAcknowledgementOutcome.REJECTED, - AppErrorCode.PERMISSION, - ) - - is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) - - else -> Unit - } - } - val result = fixture.coordinator.start(pairedRequest()) + // When + val result = fixture.coordinator.start(request()) - assertTrue(result.isFailure) - assertEquals(1, fixture.local.abortCalls) - assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) - assertEquals(0, fixture.local.commitCalls) + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertTrue(fixture.repository.commands.isEmpty()) } @Test - fun `Given no prepare ack When start times out Then three sends and both sides abort`() = runTest { + fun `Given Wear sources When recording starts Then phone starts before direct Wear start`() = runTest { + // Given val fixture = Fixture() - fixture.repository.onCommand = { command -> - if (command is WearCommand.AbortRecording) { - fixture.succeed(command.sessionId, WearSessionCommand.ABORT) - } - } - val result = fixture.coordinator.start(pairedRequest()) + // When + val result = fixture.coordinator.start(request(wearSensorIds = setOf(1))) - assertEquals(AppErrorCode.TIMEOUT, result.errorOrNull()?.code) - assertEquals(3, fixture.repository.commands.count { it is WearCommand.PrepareRecording }) - assertEquals(1, fixture.local.abortCalls) + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertTrue(fixture.repository.commands.single() is WearCommand.StartRecording) } @Test - fun `Given commit is rejected When local commit succeeded Then both devices are compensated`() = runTest { + fun `Given timed paired recording When recording starts Then both devices receive the same duration`() = runTest { + // Given val fixture = Fixture() - fixture.repository.onCommand = { command -> - when (command) { - is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) - is WearCommand.CommitRecording -> fixture.ack( - command.sessionId, - WearSessionCommand.COMMIT, - WearAcknowledgementOutcome.FAILED, - AppErrorCode.MEASUREMENT, - ) + // When + val result = fixture.coordinator.start( + request(wearSensorIds = setOf(1), durationSeconds = 12), + ) - is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + // Then + val wearStart = fixture.repository.commands.single() as WearCommand.StartRecording + assertTrue(result.isSuccess) + assertEquals(12_000L, fixture.phone.startedDurationMillis) + assertEquals(fixture.phone.startedDurationMillis, wearStart.request.durationMillis) + } - else -> Unit - } - } + @Test + fun `Given Wear start fails When recording starts Then phone and Wear are stopped`() = runTest { + // Given + val fixture = Fixture().apply { repository.failStart = true } - val result = fixture.coordinator.start(pairedRequest()) + // When + val result = fixture.coordinator.start(request(wearSensorIds = setOf(1))) + // Then assertTrue(result.isFailure) - assertEquals(1, fixture.local.commitCalls) - assertEquals(1, fixture.local.abortCalls) - assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) + assertEquals(1, fixture.phone.stopCalls) + assertTrue(fixture.repository.commands.last() is WearCommand.StopRecording) } @Test - fun `Given local stop fails When paired recording stops Then remote stop is still attempted`() = runTest { + fun `Given Wear start result is lost When recording starts Then phone keeps recording`() = runTest { + // Given + val fixture = Fixture().apply { repository.dropStartResult = true } + + // When + val result = fixture.coordinator.start( + request(wearSensorIds = setOf(1), durationSeconds = 12), + ) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertEquals(0, fixture.phone.stopCalls) + assertEquals(3, fixture.repository.commands.count { it is WearCommand.StartRecording }) + } + + @Test + fun `Given paired recording When stopped Then phone and Wear receive stop`() = runTest { + // Given val fixture = Fixture() - fixture.repository.onCommand = { command -> - when (command) { - is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) - is WearCommand.CommitRecording -> fixture.succeed(command.sessionId, WearSessionCommand.COMMIT) - is WearCommand.StopRecording -> fixture.succeed(command.sessionId, WearSessionCommand.STOP) - else -> Unit - } - } - assertTrue(fixture.coordinator.start(pairedRequest()).isSuccess) - fixture.local.stopResult = failure("Stop local recording") + fixture.coordinator.start(request(wearSensorIds = setOf(1))) + // When val result = fixture.coordinator.stop() + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.stopCalls) + assertTrue(fixture.repository.commands.last() is WearCommand.StopRecording) + } + + @Test + fun `Given active recording When another start is requested Then intent is rejected`() = runTest { + // Given + val fixture = Fixture() + fixture.coordinator.start(request()) + + // When + val result = fixture.coordinator.start(request()) + + // Then assertTrue(result.isFailure) - assertEquals(1, fixture.local.stopCalls) - assertTrue(fixture.repository.commands.any { it is WearCommand.StopRecording }) + assertEquals(AppErrorCode.CONFLICT, result.errorOrNull()?.code) + assertEquals(1, fixture.phone.startCalls) + } + + @Test + fun `Given active recording When stale Wear stop arrives Then phone keeps recording`() = runTest { + // Given + val fixture = Fixture() + fixture.coordinator.start(request()) + + // When + val result = fixture.coordinator.stopFromWatch("stale-session", WearStopReason.USER_REQUEST) + + // Then + assertTrue(result.isSuccess) + assertEquals(0, fixture.phone.stopCalls) } - private fun pairedRequest() = MeasurementRequest( + @Test + fun `Given phone timer expires while Wear is unreachable When recording starts again Then phone is available`() = + runTest { + // Given + val fixture = Fixture() + fixture.coordinator.start(request(wearSensorIds = setOf(1), durationSeconds = 12)) + val sessionId = (fixture.repository.commands.single() as WearCommand.StartRecording).sessionId + fixture.repository.failSends = true + + // When + val peerNotification = fixture.coordinator.onAutomaticPhoneStop( + sessionId, + WearStopReason.DURATION_EXPIRED, + ) + fixture.repository.failSends = false + val nextStart = fixture.coordinator.start(request()) + + // Then + assertTrue(peerNotification.isFailure) + assertTrue(nextStart.isSuccess) + assertEquals(0, fixture.phone.stopCalls) + assertEquals(2, fixture.phone.startCalls) + } + + private fun request(wearSensorIds: Set = emptySet(), durationSeconds: Int = 0) = MeasurementRequest( sensorIds = setOf(1), includesGps = false, samplingPeriodIndex = 0, - stopOnLowBattery = true, - useWakeLock = true, + stopOnLowBattery = false, + useWakeLock = false, gpsIntervalSeconds = 10, gpsMinDistanceMeters = 20, - wearSensorIds = setOf(21), + wearSensorIds = wearSensorIds, + durationSeconds = durationSeconds, ) private class Fixture { - val inbox = WearAcknowledgementInbox() - val repository = InteractiveRepository() - val local = FakePhoneRecordingController() + val inbox = WearRecordingResultInbox() + val phone = FakePhoneRecordingController() + val repository = RecordingResultRepository(inbox) val coordinator = PairedRecordingCoordinator( - localController = local, + phoneRecording = phone, sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - acknowledgementInbox = inbox, - sessionIdFactory = RecordingSessionIdFactory(), + watchResults = inbox, diagnosticLogger = DiagnosticLogger { }, - clock = EpochClock { 1_000L }, ) + } +} - fun succeed(sessionId: String, command: WearSessionCommand) { - ack(sessionId, command, WearAcknowledgementOutcome.SUCCEEDED) - } +private class FakePhoneRecordingController : PhoneRecordingController { + var startCalls = 0 + var stopCalls = 0 + var startedDurationMillis = 0L - fun ack( - sessionId: String, - command: WearSessionCommand, - outcome: WearAcknowledgementOutcome, - errorCode: AppErrorCode? = null, - ) { - inbox.publish(WearCommand.Acknowledgement(sessionId, command, outcome, errorCode)) - } + override fun start(sessionId: String, request: MeasurementRequest): AppResult { + startCalls += 1 + startedDurationMillis = request.durationSeconds.coerceAtLeast(0) * 1_000L + return AppResult.success(StartedPhoneRecording(sessionId, "fixture", startedDurationMillis)) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + stopCalls += 1 + return AppResult.success(Unit) + } + + override fun stopCurrent(reason: WearStopReason): AppResult { + stopCalls += 1 + return AppResult.success(Unit) } + + override fun annotate(text: String, timestampMillis: Long): AppResult = AppResult.success(Unit) } -private class InteractiveRepository : WearConnectionRepository { +private class RecordingResultRepository(private val inbox: WearRecordingResultInbox) : WearConnectionRepository { val commands = mutableListOf() - var onCommand: (WearCommand) -> Unit = { } + var failStart = false + var failSends = false + var dropStartResult = false override fun observeCapability(capability: String): Flow = emptyFlow() - override suspend fun findNode(capability: String): WearNode? = WearNode("wear", "Wear", isNearby = true) + override suspend fun findNode(capability: String): WearNode? = WearNode("watch", "Watch", isNearby = true) override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + if (failSends) { + return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Send Wear command")) + } + val command = WearCommandCodec.decode(payload).getOrThrow() commands += command - onCommand(command) + when (command) { + is WearCommand.StartRecording -> if (!dropStartResult) { + publish( + command.sessionId, + WearRecordingAction.START, + failed = failStart, + ) + } + + is WearCommand.StopRecording -> publish(command.sessionId, WearRecordingAction.STOP, failed = false) + + else -> Unit + } return AppResult.success(Unit) } -} - -private class FakePhoneRecordingController : PhoneRecordingController { - var commitCalls = 0 - var abortCalls = 0 - var stopCalls = 0 - var stopResult: AppResult = AppResult.success(Unit) - override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = - AppResult.success( - PreparedPhoneRecording( + private fun publish(sessionId: String, action: WearRecordingAction, failed: Boolean) { + inbox.publish( + WearCommand.RecordingResult( sessionId = sessionId, - launchRequest = MeasurementLaunchRequest( - sessionId = sessionId, - folderName = "fixture", - useInternalStorage = false, - sensorIds = request.sensorIds, - sensorSamplingPeriod = 0, - includesGps = request.includesGps, - stopOnLowBattery = request.stopOnLowBattery, - useWakeLock = request.useWakeLock, - gpsIntervalSeconds = request.gpsIntervalSeconds, - gpsMinDistanceMeters = request.gpsMinDistanceMeters, - ), + action = action, + outcome = if (failed) WearRecordingOutcome.FAILED else WearRecordingOutcome.SUCCEEDED, + errorCode = AppErrorCode.MEASUREMENT.takeIf { failed }, + errorOperation = "Start Wear recording".takeIf { failed }, + errorMessage = "Fixture start failed".takeIf { failed }, + failureCount = if (failed) 1 else 0, ), ) - - override fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult { - commitCalls += 1 - return AppResult.success(Unit) - } - - override fun abort(sessionId: String): AppResult { - abortCalls += 1 - return AppResult.success(Unit) - } - - override fun stop(sessionId: String, reason: WearStopReason): AppResult { - stopCalls += 1 - return stopResult } - - override fun stopAny(reason: WearStopReason): AppResult = stopResult - - override fun annotate(text: String, timestampMillis: Long): AppResult = AppResult.success(Unit) } - -private fun failure(operation: String): AppResult = AppResult.failure( - AppError(AppErrorCode.MEASUREMENT, operation), -) diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt deleted file mode 100644 index 412874a..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.paired - -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PhoneWearMessageDispatcherTest { - @Test - fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest { - val commands = mutableListOf() - val dispatcher = PhoneWearMessageDispatcher( - commandHandler = PhoneWearCommandPolicy { command -> - commands += command - AppResult.success(Unit) - }, - diagnosticLogger = DiagnosticLogger { }, - ) - val payload = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() - - val result = dispatcher.dispatch(PHONE_MESSAGE_PATH, payload) - - assertTrue(result.isSuccess) - assertEquals(listOf(WearCommand.LaunchPhone), commands) - } - - @Test - fun `Given a protocol v1 payload When dispatched Then command policy is not called`() = runTest { - var calls = 0 - val dispatcher = PhoneWearMessageDispatcher( - commandHandler = PhoneWearCommandPolicy { - calls += 1 - AppResult.success(Unit) - }, - diagnosticLogger = DiagnosticLogger { }, - ) - - val result = dispatcher.dispatch( - PHONE_MESSAGE_PATH, - byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1), - ) - - assertTrue(result.isFailure) - assertEquals(0, calls) - } -} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStoreTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStoreTest.kt new file mode 100644 index 0000000..70cbfaa --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WearSensorCatalogStoreTest.kt @@ -0,0 +1,25 @@ +package com.tomasrepcik.sensorbox.domain.sensors + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearSensorCatalogStoreTest { + @Test + fun `Given an empty sensor response When catalog updates Then Wear is available`() { + val catalog = WearSensorCatalogStore() + + catalog.update(emptyList()) + + assertTrue(catalog.isAvailable.value) + } + + @Test + fun `Given an available Wear app When catalog clears Then Wear is unavailable`() { + val catalog = WearSensorCatalogStore().apply { update(emptyList()) } + + catalog.clear() + + assertFalse(catalog.isAvailable.value) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt index 87d0ede..9edd339 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -8,21 +8,31 @@ import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode import com.tomasrepcik.sensorbox.core.testing.FakeAppPreferencesRepository import com.tomasrepcik.sensorbox.domain.measurement.DocumentStorageGateway +import com.tomasrepcik.sensorbox.domain.measurement.MeasurementPermissionsUseCase import com.tomasrepcik.sensorbox.domain.measurement.MeasurementRequest -import com.tomasrepcik.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.tomasrepcik.sensorbox.domain.measurement.RecordingControlUseCase +import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor import com.tomasrepcik.sensorbox.domain.sensors.WearSensorCatalogStore +import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import com.tomasrepcik.sensorbox.testing.MainDispatcherRule import com.tomasrepcik.sensorbox.wearoslib.connectivity.FakeWearConnectionRepository import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import java.io.File @@ -120,6 +130,85 @@ class FeatureViewModelTest { assertEquals(AppErrorCode.VALIDATION, viewModel.state.value.errorCode) } + @Test + fun `Given Wear permission rejection When recording starts Then Wear guidance is shown`() = runTest { + val workflow = FakeRecordingWorkflow().apply { + startResult = AppResult.failure( + AppError( + code = AppErrorCode.PERMISSION, + operation = "Handle paired PREPARE acknowledgement", + diagnosticMessage = "Paired PREPARE permission rejected", + context = mapOf("source" to "wear"), + ), + ) + } + val viewModel = recordingViewModel(workflow) + advanceUntilIdle() + + viewModel.accept(RecordingIntent.ToggleWearSensor(1)) + viewModel.accept(RecordingIntent.StartMeasurement) + advanceUntilIdle() + + assertEquals(RecordingMessage.WEAR_PERMISSION_REQUIRED, viewModel.state.value.message) + assertEquals(AppErrorCode.PERMISSION, viewModel.state.value.errorCode) + } + + @Test + fun `Given waiting start When tapped Then progress appears and duplicate taps are ignored`() = runTest { + val startGate = CompletableDeferred() + val workflow = FakeRecordingWorkflow().apply { this.startGate = startGate } + val sessionStore = MeasurementSessionStore() + val viewModel = recordingViewModel(workflow, sessionStore = sessionStore) + advanceUntilIdle() + + viewModel.accept(RecordingIntent.ToggleSensor(1)) + viewModel.accept(RecordingIntent.StartMeasurement) + runCurrent() + + assertTrue(viewModel.state.value.isStarting) + viewModel.accept(RecordingIntent.StartMeasurement) + assertEquals(1, workflow.startCalls) + + startGate.complete(Unit) + advanceUntilIdle() + + assertTrue(viewModel.state.value.isStarting) + sessionStore.markRunning( + MeasurementSessionState.Running("session", "fixture", 0L, listOf(1), false), + ) + runCurrent() + + assertFalse(viewModel.state.value.isStarting) + sessionStore.markIdle() + runCurrent() + } + + @Test + fun `Given a start delay When recording starts Then ViewModel counts down before execution`() = runTest { + // Given + val recording = FakeRecordingWorkflow() + val viewModel = recordingViewModel(recording) + advanceUntilIdle() + viewModel.accept(RecordingIntent.ToggleSensor(1)) + viewModel.accept(RecordingIntent.SetStartDelay(2)) + + // When + viewModel.accept(RecordingIntent.StartMeasurement) + runCurrent() + + // Then + assertEquals(2, viewModel.state.value.startCountdownSeconds) + assertEquals(0, recording.startCalls) + advanceTimeBy(1_000L) + runCurrent() + assertEquals(1, viewModel.state.value.startCountdownSeconds) + assertEquals(0, recording.startCalls) + advanceTimeBy(1_000L) + runCurrent() + assertEquals(null, viewModel.state.value.startCountdownSeconds) + assertEquals(1, recording.startCalls) + } + @Test fun `Given storage action When chosen Then recording emits only its picker effect`() = runTest { val viewModel = recordingViewModel(FakeRecordingWorkflow()) @@ -141,15 +230,68 @@ class FeatureViewModelTest { assertEquals(RecordingEffect.Navigate(MainRoute.RECORD), effect.await()) } - private fun recordingViewModel(workflow: RecordingWorkflowGateway): RecordingViewModel { + @Test + fun `Given a Wear catalog When observed Then every sensor detail is retained`() = runTest { + val catalog = WearSensorCatalogStore() + val viewModel = recordingViewModel(FakeRecordingWorkflow(), catalog) + advanceUntilIdle() + + catalog.update( + listOf( + WearSensorInfo( + type = 1, + name = "Wear Accelerometer", + vendor = "Fixture", + version = 7, + stringType = "android.sensor.accelerometer", + maximumRange = 78.4f, + resolution = 0.0024f, + power = 0.25f, + minimumDelayMicros = 5_000, + maximumDelayMicros = 200_000, + reportingMode = 0, + isWakeUpSensor = true, + ), + ), + ) + advanceUntilIdle() + + assertEquals( + SensorDescriptor( + type = 1, + name = "Wear Accelerometer", + vendor = "Fixture", + version = 7, + stringType = "android.sensor.accelerometer", + maximumRange = 78.4f, + resolution = 0.0024f, + power = 0.25f, + minimumDelayMicros = 5_000, + maximumDelayMicros = 200_000, + reportingMode = com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode.CONTINUOUS, + isWakeUpSensor = true, + ), + viewModel.state.value.wearSensors.single(), + ) + } + + private fun recordingViewModel( + workflow: RecordingControlUseCase, + wearSensorCatalog: WearSensorCatalogStore = WearSensorCatalogStore(), + sessionStore: MeasurementSessionStore = MeasurementSessionStore(), + ): RecordingViewModel { val repository = FakeWearConnectionRepository() return RecordingViewModel( preferencesRepository = FakeAppPreferencesRepository(), - workflow = workflow, - sessionStore = MeasurementSessionStore(), + availableSensors = AvailableSensorsUseCase { emptyList() }, + storage = FakeDocumentStorage(hasStorage = true), + permissions = MeasurementPermissionsUseCase { emptySet() }, + recording = workflow, + sessionStore = sessionStore, observeWearCapability = ObserveWearCapabilityUseCase(repository), - sendWearMessage = SendWearMessageUseCase(repository), - wearSensorCatalog = WearSensorCatalogStore(), + sendWearCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + wearSensorCatalog = wearSensorCatalog, + elapsedRealtimeClock = ElapsedRealtimeClock { 10_000L }, ) } } @@ -174,20 +316,16 @@ private class FakeDiagnosticsStore( override fun clear(): AppResult = AppResult.success(Unit) } -private class FakeRecordingWorkflow : RecordingWorkflowGateway { +private class FakeRecordingWorkflow : RecordingControlUseCase { var startResult: AppResult = AppResult.success(Unit) + var startGate: CompletableDeferred? = null + var startCalls = 0 - override fun sensors(): List = emptyList() - - override fun storagePath(): String? = "fixture" - - override fun hasStorage(): Boolean = true - - override fun persistStorage(resultIntent: Intent?): AppResult = AppResult.success(Unit) - - override fun missingPermissions(request: MeasurementRequest): Set = emptySet() - - override suspend fun start(request: MeasurementRequest): AppResult = startResult + override suspend fun start(request: MeasurementRequest): AppResult { + startCalls += 1 + startGate?.await() + return startResult + } override suspend fun stop(): AppResult = AppResult.success(Unit) diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserReducerTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserReducerTest.kt new file mode 100644 index 0000000..6dc4752 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserReducerTest.kt @@ -0,0 +1,51 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MeasurementBrowserReducerTest { + @Test + fun `zooming at chart center halves the visible time`() { + val result = MeasurementBrowserReducer.zoomSensorChartTimeWindow( + state = MeasurementBrowserState(), + zoomFactor = 2f, + focalPointFraction = 0.5f, + ) + + assertEquals(SensorChartTimeWindow(0.25f, 0.75f), result.sensorChartTimeWindow) + } + + @Test + fun `shifting chart time window cannot move beyond recording end`() { + val state = MeasurementBrowserState(sensorChartTimeWindow = SensorChartTimeWindow(0.25f, 0.75f)) + + val result = MeasurementBrowserReducer.shiftSensorChartTimeWindow( + state = state, + visibleWindowFraction = 10f, + ) + + assertEquals(SensorChartTimeWindow(0.5f, 1f), result.sensorChartTimeWindow) + } + + @Test + fun `showing entire chart restores the complete recording range`() { + val state = MeasurementBrowserState(sensorChartTimeWindow = SensorChartTimeWindow(0.4f, 0.6f)) + + val result = MeasurementBrowserReducer.showEntireSensorChartTimeRange(state) + + assertEquals(SensorChartTimeWindow(), result.sensorChartTimeWindow) + } + + @Test + fun `invalid chart zoom leaves current time window unchanged`() { + val state = MeasurementBrowserState(sensorChartTimeWindow = SensorChartTimeWindow(0.2f, 0.8f)) + + val result = MeasurementBrowserReducer.zoomSensorChartTimeWindow( + state = state, + zoomFactor = Float.NaN, + focalPointFraction = 0.5f, + ) + + assertEquals(state, result) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt new file mode 100644 index 0000000..6c4a68e --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt @@ -0,0 +1,107 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary +import com.tomasrepcik.sensorbox.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MeasurementBrowserViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given saved measurements When refreshed Then newest folders are exposed`() = runTest { + val repository = FakeMeasurementRepository() + val viewModel = MeasurementBrowserViewModel(repository) + + viewModel.onIntent(MeasurementBrowserIntent.RefreshMeasurements) + advanceUntilIdle() + + assertEquals(listOf(repository.summary), viewModel.state.value.measurements) + assertFalse(viewModel.state.value.isLoading) + } + + @Test + fun `Given a measurement When its GPS file opens Then details content and navigation are retained`() = runTest { + val repository = FakeMeasurementRepository() + val viewModel = MeasurementBrowserViewModel(repository) + val detailsEffect = async { viewModel.effects.first() } + + viewModel.onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(repository.summary.id)) + advanceUntilIdle() + + assertEquals(repository.details, viewModel.state.value.selectedMeasurement) + assertEquals( + MeasurementBrowserEffect.Navigate(MainRoute.MEASUREMENT_DETAILS), + detailsEffect.await(), + ) + + val fileEffect = async { viewModel.effects.first() } + viewModel.onIntent(MeasurementBrowserIntent.OpenMeasurementFile(repository.gpsFile.id)) + advanceUntilIdle() + + assertEquals(repository.gpsFile, viewModel.state.value.selectedFile) + assertEquals(repository.gpsContent, viewModel.state.value.selectedFileContent) + assertEquals( + MeasurementBrowserEffect.Navigate(MainRoute.MEASUREMENT_FILE), + fileEffect.await(), + ) + } + + @Test + fun `Given repository failure When refreshed Then loading stops and stable error state is exposed`() = runTest { + val repository = FakeMeasurementRepository().apply { + measurementsResult = AppResult.failure( + AppError(AppErrorCode.STORAGE, "Read fixture measurement repository"), + ) + } + val viewModel = MeasurementBrowserViewModel(repository) + + viewModel.onIntent(MeasurementBrowserIntent.RefreshMeasurements) + advanceUntilIdle() + + assertFalse(viewModel.state.value.isLoading) + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + } +} + +private class FakeMeasurementRepository : MeasurementRepository { + val summary = MeasurementSummary("session-1", "Morning walk", 1_725_000_000_000, "2026-08-25", 1) + val gpsFile = MeasurementFileSummary("gps.csv", "Gps", MeasurementFileKind.GPS, 128) + val details = MeasurementDetails( + summary, + listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")), + listOf(gpsFile), + ) + val gpsContent = MeasurementFileContent.GpsCoordinates( + listOf(GpsCoordinate(1_725_000_000_000, 48.148596, 17.107748, 140.0, 3.0, null, null, "gps")), + truncated = false, + ) + var measurementsResult: AppResult> = AppResult.success(listOf(summary)) + + override suspend fun loadMeasurements(): AppResult> = measurementsResult + + override suspend fun loadMeasurementDetails(measurementId: String): AppResult = + AppResult.success(details) + + override suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult = + AppResult.success(gpsContent) +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingReducerTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingReducerTest.kt index 27deded..f303da6 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingReducerTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingReducerTest.kt @@ -79,6 +79,20 @@ class RecordingReducerTest { assertEquals(1, actual.state.detailsSensorType) } + @Test + fun `Given a Wear sensor When its information is opened Then details retain the Wear source`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce( + givenState, + RecordingIntent.OpenSensorDetails(21, RecordingSensorSource.WEAR), + ) + + assertEquals(RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), actual.effect) + assertEquals(21, actual.state.detailsSensorType) + assertEquals(RecordingSensorSource.WEAR, actual.state.detailsSensorSource) + } + @Test fun `Given measurement setup When returning Then sensor selection is shown`() { val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt index bf1a816..c917cc0 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt @@ -52,7 +52,7 @@ class FileDiagnosticsTest { ) val text = diagnostics.readText().getOrNull().orEmpty() - assertTrue(text.contains("sessionId=session-123")) + assertTrue(text.contains("\"sessionId\":\"session-123\"")) assertFalse(text.contains("1.2,3.4,5.6")) assertFalse(text.contains("48.1,17.1")) assertFalse(text.contains("private note")) diff --git a/core/src/testFixtures/java/com/tomasrepcik/sensorbox/core/testing/FakeAppPreferencesRepository.kt b/core/src/testFixtures/java/com/tomasrepcik/sensorbox/core/testing/FakeAppPreferencesRepository.kt index 49d1269..714d91c 100644 --- a/core/src/testFixtures/java/com/tomasrepcik/sensorbox/core/testing/FakeAppPreferencesRepository.kt +++ b/core/src/testFixtures/java/com/tomasrepcik/sensorbox/core/testing/FakeAppPreferencesRepository.kt @@ -1,7 +1,6 @@ package com.tomasrepcik.sensorbox.core.testing import com.tomasrepcik.sensorbox.core.error.AppResult - import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesReducer diff --git a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt index 3fdb3cd..7223d35 100644 --- a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt +++ b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt @@ -14,139 +14,110 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class RecordingEngineTest { @Test - fun `Given unordered sources When committed Then sources start in stable order`() = runTest { + fun `Given unordered sources When recording starts Then sources start in stable order`() = runTest { + // Given val calls = mutableListOf() - val gps = FakeSource(RecordingSourceType.GPS, calls) - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) - val engine = engine(listOf(gps, sensors)) + val engine = engine( + FakeSource(RecordingSourceType.GPS, calls), + FakeSource(RecordingSourceType.SENSOR, calls), + ) val plan = plan( RecordingSourceSpec.Gps(10, 20), RecordingSourceSpec.Sensors(setOf(1), 0), ) - assertTrue(engine.prepare(plan).isSuccess) - assertTrue(engine.commit(plan.sessionId).isSuccess) + // When + val result = engine.start(plan) - assertEquals( - listOf("prepare:SENSOR", "prepare:GPS", "start:SENSOR", "start:GPS"), - calls, - ) - assertTrue(engine.state.value is RecordingSessionState.Running) + // Then + assertTrue(result.isSuccess) + assertEquals(listOf("start:SENSOR", "start:GPS"), calls) } @Test - fun `Given a partial start failure When committed Then started sources stop in reverse order`() = runTest { + fun `Given a source start fails When recording starts Then attempted sources stop in reverse order`() = runTest { + // Given val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) - val gps = FakeSource(RecordingSourceType.GPS, calls, startFails = true) - val engine = engine(listOf(gps, sensors)) + val engine = engine( + FakeSource(RecordingSourceType.GPS, calls, startFails = true), + FakeSource(RecordingSourceType.SENSOR, calls), + ) val plan = plan( RecordingSourceSpec.Sensors(setOf(1), 0), RecordingSourceSpec.Gps(10, 20), ) - engine.prepare(plan) - val result = engine.commit(plan.sessionId) + // When + val result = engine.start(plan) + // Then assertTrue(result.isFailure) assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) - assertEquals(RecordingSessionState.Idle, engine.state.value) } @Test - fun `Given a prepare failure When prepared Then failing and prepared sources clean up in reverse order`() = - runTest { - val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) - val gps = FakeSource(RecordingSourceType.GPS, calls, prepareFails = true) - val engine = engine(listOf(gps, sensors)) - val plan = plan( - RecordingSourceSpec.Sensors(setOf(1), 0), - RecordingSourceSpec.Gps(10, 20), - ) - - val result = engine.prepare(plan) - - assertTrue(result.isFailure) - assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) - assertEquals(RecordingSessionState.Idle, engine.state.value) - } - - @Test - fun `Given stop failures When stopped Then every source is attempted`() = runTest { + fun `Given stop failures When recording stops Then every started source is stopped`() = runTest { + // Given val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls, stopFails = true) - val gps = FakeSource(RecordingSourceType.GPS, calls, stopFails = true) - val engine = engine(listOf(gps, sensors)) + val engine = engine( + FakeSource(RecordingSourceType.GPS, calls, stopFails = true), + FakeSource(RecordingSourceType.SENSOR, calls, stopFails = true), + ) val plan = plan( RecordingSourceSpec.Sensors(setOf(1), 0), RecordingSourceSpec.Gps(10, 20), ) - engine.prepare(plan) - engine.commit(plan.sessionId) + engine.start(plan) + // When val result = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + // Then assertTrue(result.isFailure) assertTrue(calls.contains("stop:GPS")) assertTrue(calls.contains("stop:SENSOR")) } @Test - fun `Given a completed session When stop repeats Then sources are not stopped twice`() = runTest { + fun `Given a completed recording When stop repeats Then sources stop once`() = runTest { + // Given val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) - val engine = engine(listOf(sensors)) + val engine = engine(FakeSource(RecordingSourceType.SENSOR, calls)) val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) - engine.prepare(plan) - engine.commit(plan.sessionId) + engine.start(plan) + // When val first = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) val second = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + // Then assertEquals(first, second) assertEquals(1, calls.count { it == "stop:SENSOR" }) } @Test - fun `Given a duration When committed Then recording stops after duration`() = runTest { + fun `Given a recording duration When it expires Then started sources stop`() = runTest { + // Given val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) val engine = RecordingEngine( - sources = listOf(sensors), + sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls)), scope = backgroundScope, clock = RecordingClock { testScheduler.currentTime }, delay = RecordingDelay { kotlinx.coroutines.delay(it) }, ) val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) - engine.prepare(plan) - engine.commit(plan.sessionId) + // When + engine.start(plan) advanceTimeBy(500L) runCurrent() - assertEquals(RecordingSessionState.Idle, engine.state.value) + // Then assertEquals(1, calls.count { it == "stop:SENSOR" }) } - @Test - fun `Given a running session When low battery stops it Then terminal state returns to idle`() = runTest { - val calls = mutableListOf() - val sensors = FakeSource(RecordingSourceType.SENSOR, calls) - val engine = engine(listOf(sensors)) - val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) - engine.prepare(plan) - engine.commit(plan.sessionId) - - val result = engine.stop(plan.sessionId, RecordingStopReason.LOW_BATTERY) - - assertTrue(result.isSuccess) - assertEquals(RecordingSessionState.Idle, engine.state.value) - assertEquals(1, calls.count { it == "stop:SENSOR" }) - } - - private fun engine(sources: List) = RecordingEngine( - sources = sources, + private fun engine(vararg sources: RecordingSource) = RecordingEngine( + sources = sources.toList(), scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), clock = RecordingClock { 0L }, delay = RecordingDelay { }, @@ -155,7 +126,6 @@ class RecordingEngineTest { private fun plan(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingPlan( sessionId = RecordingSessionId.create(), sources = sources.toList(), - startAtEpochMillis = 0L, durationMillis = durationMillis, ) } @@ -163,16 +133,10 @@ class RecordingEngineTest { private class FakeSource( override val type: RecordingSourceType, private val calls: MutableList, - private val prepareFails: Boolean = false, private val startFails: Boolean = false, private val stopFails: Boolean = false, ) : RecordingSource { - override suspend fun prepare(spec: RecordingSourceSpec): AppResult { - calls += "prepare:$type" - return if (prepareFails) failure("Prepare $type") else AppResult.success(Unit) - } - - override suspend fun start(): AppResult { + override suspend fun start(spec: RecordingSourceSpec): AppResult { calls += "start:$type" return if (startFails) failure("Start $type") else AppResult.success(Unit) } diff --git a/tools/emulator/run_phone_sensor_test.sh b/tools/emulator/run_phone_sensor_test.sh deleted file mode 100755 index 0ee21b7..0000000 --- a/tools/emulator/run_phone_sensor_test.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "$0")/../.." && pwd)" -phone_serial="${PHONE_SERIAL:-emulator-5554}" -adb_bin="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb" -test_class="com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest" -runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" -result_file="$(mktemp -t sensorbox-phone-test.XXXXXX)" - -cleanup() { - rm -f "$result_file" -} -trap cleanup EXIT - -cd "$repo_root" -./gradlew :app:assembleDebug :app:assembleDebugAndroidTest -"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/debug/app-debug.apk -"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk -"$adb_bin" -s "$phone_serial" shell pm grant com.tomasrepcik.sensorbox android.permission.POST_NOTIFICATIONS || true -"$adb_bin" -s "$phone_serial" logcat -c - -"$adb_bin" -s "$phone_serial" shell am instrument -w -r -e class "$test_class" "$runner" >"$result_file" & -test_pid=$! - -sleep 3 -for value in "1.25:2.5:9.5" "-3.0:4.25:8.75" "6.5:-1.5:7.25"; do - "$adb_bin" -s "$phone_serial" emu sensor set acceleration "$value" - sleep 1 -done - -wait "$test_pid" -cat "$result_file" -grep -q "OK (1 test)" "$result_file" diff --git a/tools/emulator/run_wear_sync_test.sh b/tools/emulator/run_wear_sync_test.sh index c2ce22c..b2aaa5f 100755 --- a/tools/emulator/run_wear_sync_test.sh +++ b/tools/emulator/run_wear_sync_test.sh @@ -2,21 +2,58 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" -phone_serial="${PHONE_SERIAL:-emulator-5554}" -wear_serial="${WEAR_SERIAL:-emulator-5556}" adb_bin="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb" phone_test="com.tomasrepcik.sensorbox.emulator.WearToPhoneSyncReceiverEmulatorTest" wear_test="com.tomasrepcik.sensorbox.emulator.WearToPhoneSyncSenderEmulatorTest" runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" result_directory="$(mktemp -d -t sensorbox-wear-sync.XXXXXX)" -phone_result="$result_directory/phone.txt" -wear_result="$result_directory/wear.txt" +scenarios=( + single_csv + mixed_formats + empty_and_ignored + overwrite_existing + same_name_different_sessions + large_payload +) +phone_test_pid="" cleanup() { + if [[ -n "$phone_test_pid" ]]; then + kill "$phone_test_pid" 2>/dev/null || true + fi rm -rf "$result_directory" } trap cleanup EXIT +detect_device() { + local expected_role="$1" + local serial characteristics + local matches=() + + while read -r serial; do + characteristics="$($adb_bin -s "$serial" shell getprop ro.build.characteristics 1 && $2 == "device" { print $1 }') + + if [[ "${#matches[@]}" -ne 1 ]]; then + local serial_variable="PHONE_SERIAL" + [[ "$expected_role" == "wear" ]] && serial_variable="WEAR_SERIAL" + echo "Expected exactly one $expected_role emulator; found: ${matches[*]:-none}. Set $serial_variable." >&2 + exit 2 + fi + printf '%s' "${matches[0]}" +} + +phone_serial="${PHONE_SERIAL:-$(detect_device phone)}" +wear_serial="${WEAR_SERIAL:-$(detect_device wear)}" + install_apks() { "$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/debug/app-debug.apk "$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk @@ -25,8 +62,8 @@ install_apks() { } connect_emulators() { - "$adb_bin" -s "$phone_serial" forward tcp:5602 tcp:5601 - "$adb_bin" -s "$wear_serial" reverse tcp:5601 tcp:5602 + "$adb_bin" -s "$phone_serial" forward tcp:5602 tcp:5601 >/dev/null + "$adb_bin" -s "$wear_serial" reverse tcp:5601 tcp:5602 >/dev/null "$adb_bin" -s "$wear_serial" shell am broadcast \ -a com.google.android.gms.wearable.EMULATOR \ --es operation refresh-emulator-connection >/dev/null @@ -42,26 +79,36 @@ require_paired_emulators() { fi } -run_receiver() { +run_scenario() { + local scenario="$1" + local phone_result="$result_directory/$scenario-phone.txt" + local wear_result="$result_directory/$scenario-wear.txt" + + echo "Running paired sync scenario: $scenario" + "$adb_bin" -s "$phone_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null + "$adb_bin" -s "$wear_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null "$adb_bin" -s "$phone_serial" shell am instrument -w -r \ - -e class "$phone_test" "$runner" >"$phone_result" & + -e syncScenario "$scenario" -e class "$phone_test" "$runner" >"$phone_result" & phone_test_pid=$! + sleep 2 + + "$adb_bin" -s "$wear_serial" shell am instrument -w -r \ + -e syncScenario "$scenario" -e class "$wear_test" "$runner" >"$wear_result" + wait "$phone_test_pid" + phone_test_pid="" + + cat "$wear_result" + cat "$phone_result" + grep -q "OK (1 test)" "$wear_result" + grep -q "OK (1 test)" "$phone_result" } cd "$repo_root" -connect_emulators -require_paired_emulators ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest \ :wear:assembleDebug :wear:assembleDebugAndroidTest install_apks -run_receiver -sleep 2 - -"$adb_bin" -s "$wear_serial" shell am instrument -w -r \ - -e class "$wear_test" "$runner" >"$wear_result" -wait "$phone_test_pid" - -cat "$wear_result" -cat "$phone_result" -grep -q "OK (1 test)" "$wear_result" -grep -q "OK (1 test)" "$phone_result" +connect_emulators +require_paired_emulators +for scenario in "${scenarios[@]}"; do + run_scenario "$scenario" +done diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt new file mode 100644 index 0000000..e87ee76 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt @@ -0,0 +1,316 @@ +package com.tomasrepcik.sensorbox.emulator + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.SensorManager +import android.os.ParcelFileDescriptor +import androidx.test.platform.app.InstrumentationRegistry +import com.tomasrepcik.sensorbox.core.preferences.AppPreferences +import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences +import com.tomasrepcik.sensorbox.core.time.SystemEpochClock +import com.tomasrepcik.sensorbox.domain.measurement.WearMeasurementControlUseCase +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementIntentFactory +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import java.io.File + +internal data class WearRecordedSensor( + val type: Int, + val fileName: String, + val header: String = "t_sensor;t_unix;x;y;z;accuracy", + val columnCount: Int = 6, +) + +internal data class WearRecordingScenario( + val name: String, + val sensors: Set, + val sessionId: String = "wear-instrumentation-$name", + val samplingIndex: Int = 0, + val includesGps: Boolean = false, + val stopOnLowBattery: Boolean = false, + val useWakeLock: Boolean = false, + val gpsIntervalSeconds: Int = 10, + val gpsMinDistanceMeters: Int = 20, + val durationMillis: Long = 0L, +) + +internal data class WearTestLocation( + val latitude: Double, + val longitude: Double, + val accuracyMeters: Int = 3, +) + +internal class WearRecordingEmulatorFixture(private val context: Context) { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val sensorManager = context.getSystemService(SensorManager::class.java) + private val controller = WearMeasurementControlUseCase( + context = context, + intentFactory = MeasurementIntentFactory(context, SystemEpochClock), + ) + private var mockGpsActive = false + + fun prepareDevice() { + assumeTrue( + "Wear recording scenarios require Wear OS", + context.packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH), + ) + grantRuntimePermissions() + resetBattery() + } + + fun cleanUpDevice() { + controller.stop() + resetBattery() + removeMockGps() + } + + fun batteryAt(level: Int): WearRecordingEmulatorFixture = apply { + setBatteryLevel(level) + } + + fun start(scenario: WearRecordingScenario): ActiveWearRecording { + scenario.sensors.forEach { sensor -> + assertTrue( + "Wear device is missing sensor type ${sensor.type}", + sensorManager.getDefaultSensor(sensor.type) != null, + ) + } + measurementDirectory(scenario.name).deleteRecursively() + if (scenario.includesGps) prepareMockGps() + val scheduledStartMillis = SystemEpochClock.nowMillis() + val preferences = AppPreferences( + recording = RecordingPreferences( + gpsIntervalSeconds = scenario.gpsIntervalSeconds, + gpsMinDistanceMeters = scenario.gpsMinDistanceMeters, + sensorSamplingPeriod = scenario.samplingIndex, + restrictMeasurementOnLowBattery = scenario.stopOnLowBattery, + useWakeLock = scenario.useWakeLock, + ), + ) + controller.start( + sensorIds = scenario.sensors.mapTo(mutableSetOf(), WearRecordedSensor::type), + includesGps = scenario.includesGps, + preferences = preferences, + sessionId = scenario.sessionId, + folderName = scenario.name, + durationMillis = scenario.durationMillis, + ).getOrThrow() + return ActiveWearRecording(this, scenario, scheduledStartMillis) + } + + private fun grantRuntimePermissions() { + val requestedPermissions = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + .orEmpty() + .toSet() + RUNTIME_PERMISSIONS + .filter(requestedPermissions::contains) + .forEach { permission -> + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, permission) + } + } + + private fun prepareMockGps() { + shell("appops set 2000 android:mock_location allow") + shell( + "cmd location providers add-test-provider gps " + + "--requiresSatellite --supportsAltitude --supportsSpeed --supportsBearing", + ) + shell("cmd location providers set-test-provider-enabled gps true") + mockGpsActive = true + } + + private fun provideLocations(locations: List) { + Thread.sleep(LOCATION_STARTUP_MILLIS) + locations.forEach { location -> + shell( + "cmd location providers set-test-provider-location gps " + + "--location ${location.latitude},${location.longitude} " + + "--accuracy ${location.accuracyMeters} --time ${SystemEpochClock.nowMillis()}", + ) + Thread.sleep(LOCATION_UPDATE_MILLIS) + } + } + + private fun removeMockGps() { + if (!mockGpsActive) return + shell("cmd location providers remove-test-provider gps") + shell("appops set 2000 android:mock_location deny") + mockGpsActive = false + } + + private fun setBatteryLevel(level: Int) { + require(level in 0..100) { "Battery level must be between 0 and 100" } + shell("dumpsys battery unplug -f") + shell("dumpsys battery set -f level $level") + } + + private fun resetBattery() { + shell("dumpsys battery reset -f") + } + + private fun shell(command: String): String = + ParcelFileDescriptor.AutoCloseInputStream(instrumentation.uiAutomation.executeShellCommand(command)) + .bufferedReader() + .use { it.readText() } + + private fun complete(scenario: WearRecordingScenario, scheduledStartMillis: Long): WearRecordingOutput { + val directory = measurementDirectory(scenario.name) + return WearRecordingOutput( + scenario = scenario, + scheduledStartMillis = scheduledStartMillis, + metadata = awaitMetadata(directory), + files = directory.listFiles().orEmpty().associateBy(File::getName), + ) + } + + private fun rows(scenario: WearRecordingScenario, sensor: WearRecordedSensor): List { + val output = File(measurementDirectory(scenario.name), sensor.fileName) + repeat(FILE_WAIT_ATTEMPTS) { + val rows = output.takeIf(File::isFile)?.readLines().orEmpty() + if (rows.isNotEmpty()) return rows + Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) + } + return output.takeIf(File::isFile)?.readLines().orEmpty() + } + + private fun awaitMetadata(directory: File): JSONObject { + val output = File(directory, METADATA_FILE) + repeat(FILE_WAIT_ATTEMPTS) { + output.takeIf { it.isFile && it.length() > 0L }?.let { return JSONObject(it.readText()) } + Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) + } + assertTrue("Wear recording did not finalize $output", output.isFile) + return JSONObject(output.readText()) + } + + private fun measurementDirectory(name: String): File = File(context.filesDir, "SensorBox/$name") + + internal class ActiveWearRecording( + private val fixture: WearRecordingEmulatorFixture, + private val scenario: WearRecordingScenario, + private val scheduledStartMillis: Long, + ) { + fun waitFor(delayMillis: Long): ActiveWearRecording = apply { + Thread.sleep(delayMillis) + } + + fun provideLocations(vararg locations: WearTestLocation): ActiveWearRecording = apply { + fixture.provideLocations(locations.toList()) + } + + fun dropBatteryTo(level: Int): ActiveWearRecording = apply { + fixture.setBatteryLevel(level) + } + + fun assertOnlyHeaders(): ActiveWearRecording = apply { + scenario.sensors.forEach { sensor -> + assertEquals(listOf(sensor.header), fixture.rows(scenario, sensor)) + } + } + + fun stop(): WearRecordingOutput { + fixture.controller.stop().getOrThrow() + return fixture.complete(scenario, scheduledStartMillis) + } + + fun awaitAutomaticStop(): WearRecordingOutput = fixture.complete(scenario, scheduledStartMillis) + } + + internal class WearRecordingOutput( + private val scenario: WearRecordingScenario, + private val scheduledStartMillis: Long, + private val metadata: JSONObject, + private val files: Map, + ) { + fun assertRecorded(): WearRecordingOutput = apply { + assertSessionMetadata() + assertExactFiles() + scenario.sensors.forEach { sensor -> assertValidCsv(files.getValue(sensor.fileName), sensor) } + if (scenario.includesGps) assertGpsCsv(files.getValue(GPS_FILE)) + } + + fun assertCancelledBeforeStart(): WearRecordingOutput = apply { + assertSessionMetadata() + assertExactFiles() + scenario.sensors.forEach { sensor -> + assertEquals(listOf(sensor.header), files.getValue(sensor.fileName).readLines()) + } + } + + private fun assertSessionMetadata() { + assertEquals(scenario.name, metadata.getString("folder")) + assertEquals(scenario.durationMillis.coerceAtLeast(0), metadata.getLong("durationMillis")) + val recordedTypes = metadata.getJSONArray("ranges").let { ranges -> + buildSet { + repeat(ranges.length()) { add(ranges.getJSONObject(it).getInt("type")) } + } + } + assertEquals(scenario.sensors.mapTo(mutableSetOf(), WearRecordedSensor::type), recordedTypes) + } + + private fun assertExactFiles() { + val expected = scenario.sensors.mapTo(mutableSetOf(), WearRecordedSensor::fileName).apply { + add(METADATA_FILE) + if (scenario.includesGps) add(GPS_FILE) + } + assertEquals(expected, files.keys) + } + + private fun assertGpsCsv(file: File) { + val rows = file.readLines() + assertEquals(GPS_HEADER, rows.first()) + assertTrue("Expected Wear GPS samples in $file, got $rows", rows.size >= MINIMUM_ROWS) + rows.drop(1).forEach { row -> + val columns = row.split(';') + assertEquals("Malformed Wear GPS row: $row", GPS_COLUMN_COUNT, columns.size) + assertTrue("Malformed Wear GPS timestamp: $row", columns[0].toLongOrNull() != null) + assertTrue("Malformed Wear GPS coordinates: $row", columns.slice(1..6).all { it.toDoubleOrNull() != null }) + assertTrue("Missing Wear GPS provider: $row", columns[7].isNotBlank()) + } + } + + private fun assertValidCsv(file: File, sensor: WearRecordedSensor) { + val rows = file.readLines() + assertTrue("Expected Wear sensor samples in $file, got $rows", rows.size >= MINIMUM_ROWS) + assertEquals(sensor.header, rows.first()) + val samples = rows.drop(1).map { row -> + val columns = row.split(';') + assertEquals("Malformed Wear CSV row: $row", sensor.columnCount, columns.size) + assertTrue("Non-numeric Wear CSV row: $row", columns.all { it.toDoubleOrNull() != null }) + columns + } + val sensorTimestamps = samples.map { it[0].toLong() } + val unixTimestamps = samples.map { it[1].toLong() } + assertEquals(sensorTimestamps.sorted(), sensorTimestamps) + assertEquals(unixTimestamps.sorted(), unixTimestamps) + assertTrue( + "A Wear sample was written before recording started: $unixTimestamps", + unixTimestamps.all { it >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS }, + ) + } + } + + private companion object { + val RUNTIME_PERMISSIONS = listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACTIVITY_RECOGNITION, + Manifest.permission.POST_NOTIFICATIONS, + ) + const val METADATA_FILE = "extra.json" + const val GPS_FILE = "gps.csv" + const val GPS_HEADER = "time_millis;latitude;longitude;altitude;accuracy;speed;bearing;provider" + const val GPS_COLUMN_COUNT = 8 + const val MINIMUM_ROWS = 2 + const val TIMESTAMP_TOLERANCE_MILLIS = 250L + const val FILE_WAIT_ATTEMPTS = 40 + const val FILE_WAIT_INTERVAL_MILLIS = 250L + const val LOCATION_STARTUP_MILLIS = 1_000L + const val LOCATION_UPDATE_MILLIS = 1_100L + } +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt new file mode 100644 index 0000000..27bce9c --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt @@ -0,0 +1,136 @@ +package com.tomasrepcik.sensorbox.emulator + +import android.content.Context +import android.hardware.Sensor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WearSensorRecordingEmulatorTest { + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val fixture = WearRecordingEmulatorFixture(context) + + @Before + fun prepareDevice() = fixture.prepareDevice() + + @After + fun cleanUpDevice() = fixture.cleanUpDevice() + + @Test + fun givenAnEverydayAccelerometerRecordingWhenStoppedThenItsCsvAndMetadataAreComplete() { + fixture.start( + WearRecordingScenario( + name = "WEAR_ACCELEROMETER_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + samplingIndex = 0, + ), + ) + .waitFor(2_000) + .stop() + .assertRecorded() + } + + @Test + fun givenTwoMotionSensorsAndAWakeLockWhenStoppedThenBothFilesContainSamples() { + fixture.start( + WearRecordingScenario( + name = "WEAR_MULTI_SENSOR_RECORDING_TEST", + sensors = setOf(ACCELEROMETER, GYROSCOPE), + samplingIndex = 1, + useWakeLock = true, + ), + ) + .waitFor(2_000) + .stop() + .assertRecorded() + } + + @Test + fun givenAWalkingRouteWhenLocationsChangeThenGpsAndMotionFilesAreComplete() { + fixture.start( + WearRecordingScenario( + name = "WEAR_GPS_RECORDING_TEST", + sessionId = "gps-session-from-watch", + sensors = setOf(ACCELEROMETER), + samplingIndex = 2, + includesGps = true, + gpsIntervalSeconds = 1, + gpsMinDistanceMeters = 0, + ), + ) + .provideLocations( + WearTestLocation(48.1486, 17.1077), + WearTestLocation(48.1491, 17.1082), + WearTestLocation(48.1498, 17.1090), + ) + .stop() + .assertRecorded() + } + + @Test + fun givenATimedRecordingWhenDurationExpiresThenSamplesAreSaved() { + fixture.start( + WearRecordingScenario( + name = "WEAR_TIMED_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + samplingIndex = 3, + durationMillis = 2_000, + ), + ) + .awaitAutomaticStop() + .assertRecorded() + } + + @Test + fun givenARecordingWhenStoppedDirectlyThenSamplesAreSaved() { + fixture.start( + WearRecordingScenario( + name = "WEAR_CANCELLED_RECORDING_TEST", + sensors = setOf(ACCELEROMETER, GYROSCOPE), + ), + ) + .waitFor(1_000) + .stop() + .assertRecorded() + } + + @Test + fun givenLowBatteryProtectionWhenBatteryDropsThenTheRecordingFinalizes() { + fixture + .batteryAt(100) + .start( + WearRecordingScenario( + name = "WEAR_LOW_BATTERY_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + stopOnLowBattery = true, + ), + ) + .waitFor(1_000) + .dropBatteryTo(1) + .awaitAutomaticStop() + .assertRecorded() + } + + @Test + fun givenAnUnknownSamplingIndexWhenStoppedThenFastestFallbackStillRecords() { + fixture.start( + WearRecordingScenario( + name = "WEAR_UNKNOWN_SAMPLING_RECORDING_TEST", + sensors = setOf(ACCELEROMETER), + samplingIndex = Int.MAX_VALUE, + ), + ) + .waitFor(1_000) + .stop() + .assertRecorded() + } + + private companion object { + val ACCELEROMETER = WearRecordedSensor(Sensor.TYPE_ACCELEROMETER, "accelerometer.csv") + val GYROSCOPE = WearRecordedSensor(Sensor.TYPE_GYROSCOPE, "gyroscope.csv") + } +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt index 6b0145f..8a017b8 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt @@ -3,6 +3,10 @@ package com.tomasrepcik.sensorbox.emulator import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.Wearable import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.domain.sync.SyncWearMeasurementsUseCase @@ -20,26 +24,33 @@ import java.io.File @RunWith(AndroidJUnit4::class) class WearToPhoneSyncSenderEmulatorTest { private val context: Context = ApplicationProvider.getApplicationContext() + private val scenario = WearSyncEmulatorFixture.scenario( + requireNotNull( + InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT), + ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" }, + ) @Before - fun createSingleMeasurementFixture() { + fun createScenarioFixture() { val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY) root.deleteRecursively() - val measurementDirectory = File(root, WearSyncEmulatorFixture.MEASUREMENT_NAME) - check(measurementDirectory.mkdirs()) - File(measurementDirectory, WearSyncEmulatorFixture.FILE_NAME) - .writeText(WearSyncEmulatorFixture.CONTENT) + scenario.emptyMeasurementNames.forEach { File(root, it).mkdirs() } + scenario.files.forEach { fixture -> + File(root, fixture.measurementName).also(File::mkdirs) + .resolve(fixture.fileName) + .writeBytes(fixture.content) + } } @Test - fun givenPairedEmulatorsWhenWearSyncsThenOneCsvIsTransferred() = runBlocking { + fun givenPairedEmulatorsWhenWearSyncsThenEverySupportedFileIsTransferred() = runBlocking { val sync = SyncWearMeasurementsUseCase( context = context, connectionRepository = GooglePlayWearConnectionRepository(context), transferClient = GooglePlayWearFileTransferClient(context), ) - assertEquals(1, syncWhenPhoneBecomesReachable(sync)) + assertEquals(scenario.transferredFiles.size, syncWhenPhoneBecomesReachable(sync)) } private suspend fun syncWhenPhoneBecomesReachable(sync: SyncWearMeasurementsUseCase): Int { @@ -51,10 +62,19 @@ class WearToPhoneSyncSenderEmulatorTest { } delay(POLL_INTERVAL_MILLIS) } - val message = "Phone emulator did not become reachable: ${lastFailure?.code}" + val message = "Phone emulator did not become reachable: $lastFailure; ${connectionDiagnostics()}" throw AssertionError(message, lastFailure?.cause) } + private suspend fun connectionDiagnostics(): String = runCatching { + val nodes = Tasks.await(Wearable.getNodeClient(context).connectedNodes).joinToString { it.displayName } + val capabilities = Tasks.await( + Wearable.getCapabilityClient(context).getAllCapabilities(CapabilityClient.FILTER_ALL), + ) + .mapValues { (_, info) -> info.nodes.map { it.displayName } } + "connectedNodes=[$nodes], capabilities=$capabilities" + }.getOrElse { "diagnostics failed: $it" } + private companion object { const val POLL_INTERVAL_MILLIS = 1_000L const val MAX_ATTEMPTS = 60 diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt index 40ac8a9..85216d1 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt @@ -21,7 +21,15 @@ class WearMenuRobot( rule.onNodeWithContentDescription("Record").performClick() } + fun whenSyncIsTapped() = apply { + rule.onNodeWithContentDescription("Sync recordings").performClick() + } + fun thenRecordIsVisible() = apply { rule.onNodeWithContentDescription("Record").assertIsDisplayed() } + + fun thenSyncIsVisible() = apply { + rule.onNodeWithContentDescription("Sync recordings").assertIsDisplayed() + } } diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt index 968fbc5..7a5bc9a 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt @@ -21,4 +21,17 @@ class WearMenuScreenTest { assertEquals(WearMenuDestination.RECORD, selectedDestination) } } + + @Test + fun givenMenuWhenSyncIsTappedThenSyncDestinationIsSelected() { + var selectedDestination: WearMenuDestination? = null + WearMenuRobot(composeRule) + .givenMenu { selectedDestination = it } + .thenSyncIsVisible() + .whenSyncIsTapped() + + composeRule.runOnIdle { + assertEquals(WearMenuDestination.SYNC, selectedDestination) + } + } } diff --git a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt index c6672d3..a519c5b 100644 --- a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt +++ b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt @@ -15,12 +15,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material3.Text import com.android.tools.screenshot.PreviewTest +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.DisplayPreferences import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences import com.tomasrepcik.sensorbox.domain.sensors.WearSensorDescriptor import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer @PreviewTest @ReadmeWearPreview @@ -94,9 +94,24 @@ private fun WearReadmeFrame(state: WearDashboardState) { } private fun readmeWearSensors() = listOf( - WearSensorDescriptor(1, "Goldfish 3-axis Accelerometer", "Android"), - WearSensorDescriptor(4, "Goldfish 3-axis Gyroscope", "Android"), - WearSensorDescriptor(2, "Goldfish 3-axis Magnetic field sensor", "Android"), + readmeWearSensor(1, "Goldfish 3-axis Accelerometer"), + readmeWearSensor(4, "Goldfish 3-axis Gyroscope"), + readmeWearSensor(2, "Goldfish 3-axis Magnetic field sensor"), +) + +private fun readmeWearSensor(type: Int, name: String) = WearSensorDescriptor( + type = type, + name = name, + vendor = "Android", + version = 1, + stringType = "android.sensor.fixture", + maximumRange = 20f, + resolution = 0.01f, + power = 0.1f, + minimumDelayMicros = 10_000, + maximumDelayMicros = 200_000, + reportingMode = 0, + isWakeUpSensor = false, ) @Preview( diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt index 832f60c..0524e03 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt @@ -1,9 +1,11 @@ package com.tomasrepcik.sensorbox.communication +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingController +import com.tomasrepcik.sensorbox.domain.measurement.WearRecordingControlUseCase import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection @@ -12,6 +14,7 @@ import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason @@ -24,71 +27,119 @@ import org.junit.Test class WearCommandHandlerTest { @Test - fun `Given duplicate session commands When handled Then side effects run once and acknowledgements repeat`() = - runTest { - val fixture = Fixture() - val prepare = WearCommand.PrepareRecording("session-123", request()) - val commit = WearCommand.CommitRecording("session-123", 1_800_000_000_000L) - val stop = WearCommand.StopRecording("session-123", WearStopReason.USER_REQUEST) - - fixture.handler.handle(prepare) - fixture.handler.handle(prepare) - fixture.handler.handle(commit) - fixture.handler.handle(commit) - fixture.handler.handle(stop) - fixture.handler.handle(stop) - - assertEquals(1, fixture.environment.prepareCalls) - assertEquals(1, fixture.controller.startCalls) - assertEquals(1, fixture.controller.stopCalls) - assertEquals(fixture.repository.commands[0], fixture.repository.commands[1]) - assertEquals(fixture.repository.commands[2], fixture.repository.commands[3]) - assertEquals(fixture.repository.commands[4], fixture.repository.commands[5]) - } + fun `Given duplicate start and stop commands When handled Then recording effects run once`() = runTest { + // Given + val fixture = Fixture() + val start = WearCommand.StartRecording("session-123", request()) + val stop = WearCommand.StopRecording("session-123", WearStopReason.USER_REQUEST) + + // When + fixture.handler.handle(start) + fixture.handler.handle(start) + fixture.handler.handle(stop) + fixture.handler.handle(stop) + + // Then + assertEquals(1, fixture.requirements.validationCalls) + assertEquals(1, fixture.recording.startCalls) + assertEquals(1, fixture.recording.stopCalls) + assertEquals(fixture.repository.commands[0], fixture.repository.commands[1]) + assertEquals(fixture.repository.commands[2], fixture.repository.commands[3]) + } @Test - fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest { + fun `Given encoded start command When dispatched Then direct recording starts`() = runTest { + // Given val fixture = Fixture() val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { }) - val payload = WearCommandCodec.encode(WearCommand.PrepareRecording("session-123", request())).getOrThrow() + val payload = WearCommandCodec.encode(WearCommand.StartRecording("session-123", request())).getOrThrow() + // When val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, payload) + // Then assertTrue(result.isSuccess) - assertEquals(1, fixture.environment.prepareCalls) + assertEquals(1, fixture.recording.startCalls) } @Test - fun `Given an old protocol payload When dispatched Then it is rejected before policy runs`() = runTest { + fun `Given start validation fails When handled Then diagnostic result reaches phone`() = runTest { + // Given val fixture = Fixture() - val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { }) + fixture.requirements.validationResult = AppResult.failure( + AppError( + code = AppErrorCode.PERMISSION, + operation = "Validate Wear recording permissions", + diagnosticMessage = "Wear OS is missing required recording permissions", + context = mapOf("missingPermissions" to "android.permission.ACCESS_FINE_LOCATION"), + ), + ) - val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1)) + // When + fixture.handler.handle(WearCommand.StartRecording("session-123", request())) + + // Then + val result = fixture.repository.commands.single() as WearCommand.RecordingResult + assertEquals(WearRecordingOutcome.FAILED, result.outcome) + assertEquals(AppErrorCode.PERMISSION, result.errorCode) + assertEquals("Validate Wear recording permissions", result.errorOperation) + assertEquals( + "android.permission.ACCESS_FINE_LOCATION", + result.errorContext["missingPermissions"], + ) + } - assertTrue(result.isFailure) - assertEquals(0, fixture.environment.prepareCalls) + @Test + fun `Given sensor discovery command When handled Then available sensors are returned`() = runTest { + // Given + val fixture = Fixture() + fixture.requirements.sensors = listOf(sensor()) + + // When + fixture.handler.handle(WearCommand.RequestAvailableSensors) + + // Then + assertEquals(WearCommand.AvailableSensors(listOf(sensor())), fixture.repository.commands.single()) } - private fun request() = WearRecordingRequest( - folderName = "fixture", - sensorIds = listOf(1), - includesGps = false, - ) + @Test + fun `Given Wear timer expires while phone is unreachable When recording starts again Then Wear is available`() = + runTest { + // Given + val fixture = Fixture() + fixture.handler.handle(WearCommand.StartRecording("session-123", request())) + fixture.repository.failSends = true + + // When + val peerNotification = fixture.handler.onAutomaticStop(WearStopReason.DURATION_EXPIRED) + fixture.repository.failSends = false + val nextStart = fixture.handler.handle(WearCommand.StartRecording("session-456", request())) + + // Then + assertTrue(peerNotification.isFailure) + assertTrue(nextStart.isSuccess) + assertEquals(0, fixture.recording.stopCalls) + assertEquals(2, fixture.recording.startCalls) + } + + private fun request() = WearRecordingRequest("fixture", listOf(1), includesGps = false) + + private fun sensor() = WearSensorInfo(1, "Accelerometer", "Fixture", 1, "sensor", 1f, 1f, 1f, 1, 1, 0, false) private class Fixture { val repository = CapturingRepository() - val controller = FakeWearRecordingController() - val environment = FakeWearCommandEnvironment() + val recording = FakeWearRecordingControl() + val requirements = FakeWearRecordingRequirements() val handler = WearCommandHandler( - recordingController = controller, - environment = environment, + recording = recording, + environment = requirements, sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - acknowledgementInbox = WearAcknowledgementInbox(), + phoneResults = PhoneRecordingResultInbox(), ) } } -private class FakeWearRecordingController : WearRecordingController { +private class FakeWearRecordingControl : WearRecordingControlUseCase { var startCalls = 0 var stopCalls = 0 @@ -96,7 +147,6 @@ private class FakeWearRecordingController : WearRecordingController { sessionId: String, request: WearRecordingRequest, preferences: AppPreferences, - startAtEpochMillis: Long, ): AppResult { startCalls += 1 return AppResult.success(Unit) @@ -108,25 +158,32 @@ private class FakeWearRecordingController : WearRecordingController { } } -private class FakeWearCommandEnvironment : WearCommandEnvironment { - var prepareCalls = 0 +private class FakeWearRecordingRequirements : WearRecordingRequirementsUseCase { + var validationCalls = 0 + var validationResult: AppResult = AppResult.success(AppPreferences()) + var sensors: List = emptyList() - override suspend fun prepare(request: WearRecordingRequest): AppResult { - prepareCalls += 1 - return AppResult.success(AppPreferences()) + override suspend fun validate(request: WearRecordingRequest): AppResult { + validationCalls += 1 + return validationResult } - override fun sensors(): List = emptyList() + override fun availableSensors(): List = sensors } private class CapturingRepository : WearConnectionRepository { val commands = mutableListOf() + var failSends = false override fun observeCapability(capability: String): Flow = emptyFlow() override suspend fun findNode(capability: String): WearNode? = WearNode("phone", "Phone", isNearby = true) override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + if (failSends) { + return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Send phone command")) + } + commands += WearCommandCodec.decode(payload).getOrThrow() return AppResult.success(Unit) } diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt index 5e153b0..da50589 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/domain/measurement/WearMeasurementPermissionUseCaseTest.kt @@ -6,10 +6,18 @@ import org.junit.Test class WearMeasurementPermissionUseCaseTest { @Test - fun `Given Wear GPS When permissions are derived Then only notification and location are requested`() { + fun `Given Wear GPS When permissions are derived Then only location is required`() { assertEquals( - setOf(Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.ACCESS_FINE_LOCATION), - requiredWearMeasurementPermissions(includesGps = true, sdkInt = 37), + setOf(Manifest.permission.ACCESS_FINE_LOCATION), + requiredWearMeasurementPermissions(includesGps = true), + ) + } + + @Test + fun `Given Wear sensors without GPS When permissions are derived Then none are required`() { + assertEquals( + emptySet(), + requiredWearMeasurementPermissions(includesGps = false), ) } } diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt index e2b27ee..c9ef21d 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt @@ -2,6 +2,7 @@ package com.tomasrepcik.sensorbox.presentation.dashboard import com.tomasrepcik.sensorbox.core.testing.AppPreferencesFixtures import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -31,6 +32,16 @@ class WearDashboardReducerTest { assertEquals(30, actual.preferences.recording.gpsIntervalSeconds) } + @Test + fun `Given menu When sync is selected Then transfer screen is opened`() { + val actual = WearDashboardReducer.reduce( + WearDashboardState(), + WearDashboardIntent.Open(WearMenuDestination.SYNC), + ) + + assertEquals(WearRoute.SETTINGS, actual.route) + } + @Test fun `Given live picker When sensor is chosen Then chart route retains sensor`() { val givenState = WearDashboardState(route = WearRoute.LIVE) @@ -40,4 +51,17 @@ class WearDashboardReducerTest { assertEquals(WearRoute.LIVE, actual.route) assertEquals(4, actual.liveSensorType) } + + @Test + fun `Given recording screen When recording finishes Then menu with sync is restored`() { + val active = WearDashboardReducer.measurementSessionChanged( + WearDashboardState(route = WearRoute.RECORD), + MeasurementSessionState.Running("session", "fixture", 1L, listOf(1), false), + ) + + val finished = WearDashboardReducer.measurementSessionChanged(active, MeasurementSessionState.Idle) + + assertEquals(WearRoute.ACTIVE, active.route) + assertEquals(WearRoute.MENU, finished.route) + } } diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt index 5f7212c..9f45d50 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt @@ -6,6 +6,7 @@ import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUse import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -42,6 +43,6 @@ class PhoneLaunchViewModelTest { private fun createViewModel(repository: FakeWearConnectionRepository) = PhoneLaunchViewModel( ObserveWearCapabilityUseCase(repository), - SendWearMessageUseCase(repository), + SendWearCommandUseCase(SendWearMessageUseCase(repository)), ) }