diff --git a/.gitignore b/.gitignore index 618ac91..54fe7db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ /app/*.json /wear/*.json /.codebase-memory/ +.DS_Store diff --git a/README.md b/README.md index aaea8ed..ad39085 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,79 @@ -# SensorBox - -SensorBox records raw Android and Wear OS sensor samples to local CSV files. The phone app uses Android's system folder picker; recordings never require cloud storage or an account. - -This is the hard-cut Android 17 generation of the project. It does not retain the former Fragment/XML UI, `SharedPreferences`, Firebase, Maps, or compatibility migrations for old settings. - -## Screenshots - -| Sensor selection | Recording setup | -|:---:|:---:| -| SensorBox source selection | SensorBox recording setup | - -| Wear dashboard | Wear live-sensor picker | -|:---:|:---:| -| SensorBox Wear OS dashboard | SensorBox Wear OS live-sensor picker | - -### First-run introduction - -| Welcome | Local data | Privacy and terms | -|:---:|:---:|:---:| -| SensorBox welcome introduction | SensorBox local-data introduction | SensorBox privacy and terms introduction | - -| Android lifecycle | Battery optimization | Recording archive | -|:---:|:---:|:---:| -| SensorBox Android lifecycle introduction | SensorBox battery optimization introduction | SensorBox recording-folder introduction | - -The introduction uses tintable vector illustrations that follow the app theme. Privacy Policy, Terms of Use, battery optimization, and recording-archive selection use native Android screens. A recording archive remains mandatory before setup can finish. - -All screenshots above come from deterministic Compose preview fixtures. Refresh the complete gallery on the host without an emulator or connected device: - -```shell -./gradlew refreshReadmeScreenshots +

+ SensorBox app icon +

+ +

SensorBox

+ +

+ Raw phone and watch sensors, recorded to files you own. +

+ +

+ Android 10+ + Wear OS + Jetpack Compose +

+ +## See it in use + + + + + + + + + + + + + + +
SensorBox phone sensor selectionSensorBox recording setupSensorBox Wear OS dashboardSensorBox live watch sensor
Choose exactly what to capture.Set timing and recording rules.Record from the watch on its own.See a live signal before committing.
+ +## The idea + +SensorBox turns Android and Wear OS hardware into a field recorder. Pick the sensors, start a visible recording, and get plain CSV files in a folder you chose. No account, analytics SDK, or cloud storage sits in the middle. + +## What it does + +- Records available phone and watch sensors at Android sampling periods +- Adds foreground GPS samples when requested +- Runs through an explicit foreground service with safe stop paths +- Shows a live watch signal with a Compose chart +- Sends watch recordings to the phone through the Wear OS Channel API +- Writes phone measurements through Android's Storage Access Framework +- Keeps active recordings alive if the paired device disconnects + +## How it is built + +Phone and watch screens use MVI: + +```text +Composable -> Intent -> ViewModel -> use case -> repository -> State + Effect ``` -## Current feature set - -- Record available phone or watch sensors at Android sampling periods. -- Record foreground GPS samples alongside sensor data. -- Run recording work in an explicit foreground service with health/location service types. -- Stop safely from the app, watch, notification, low-battery policy, or a paired-device command. -- Preview a live watch sensor with a Compose-native chart. -- Stream watch recordings to the phone with the Wear OS Channel API. -- Store phone measurements in a user-selected recording archive through the Storage Access Framework. -- Follow system/dynamic color with light, dark, and custom fallback palettes. +| Module | Purpose | +| --- | --- | +| `app` | Phone UI, permissions, paired recording, and received watch files | +| `wear` | Watch UI, live charts, and standalone recording | +| `recording-core` | Pure Kotlin recording state machine and cleanup rules | +| `sensorservices` | Android sensor, GPS, foreground-service, and file adapters | +| `WearOsLib` | Versioned commands and Channel file transport | +| `core` / `core-common` | Preferences, storage, diagnostics, results, and errors | -## Architecture +Hilt wires the Android implementations behind testable interfaces. Each device owns its recording clock and local files. -The UI modules use unidirectional MVI: +## Build it -`Composable → Intent → ViewModel → use case → repository/service → State + Effect` - -UI launchers execute one-shot effects, while decisions and state transitions remain in workflow-owned ViewModels, reducers, and focused use cases. The phone shell owns navigation only. Hilt provides production dependencies and interfaces keep platform boundaries replaceable in tests. - -Modules: - -- `app`: phone Compose UI, workflow-owned MVI, paired-recording policy, permissions, and received watch files. -- `wear`: Wear Compose Material 3 UI, MVI, live charts, recording, and phone launch flow. -- `core-common`: platform-neutral `AppResult`, stable application errors, and diagnostics contracts. -- `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 v5 JSON commands, and Channel file transport. App policy stays in `app` and `wear`. - -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 - -- Android Gradle Plugin 9.3.2 and Gradle 9.7 -- Android compile/target SDK 37 (Android 17) -- Java 17 and Kotlin 2.4.10 -- Jetpack Compose Material 3 and Wear Compose Material 3 -- Hilt 2.60.1 -- DataStore Preferences 1.2.1 -- Detekt 2 with formatting rules and no baselines - -Every Kotlin function is checked at a maximum of 40 lines. Compose functions therefore also stay below the requested 60-line ceiling. - -## Build and quality checks - -Install JDK 17 and Android SDK 37, then run: +You need JDK 17 and Android SDK 37. A Wear OS device or emulator is only needed to run the watch app and paired tests. ```shell ./gradlew :app:assembleDebug :wear:assembleDebug ./gradlew testDebugUnitTest detekt ./gradlew :app:lintDebug :wear:lintDebug -./gradlew :app:validateDebugScreenshotTest :wear:validateDebugScreenshotTest -``` - -Instrumentation test sources can be compiled without a device: - -```shell -./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin ``` -Tests use Given/When/Then naming, reusable state/repository fixtures, coroutine test contexts, and Compose robots for end-to-end UI interactions. - -## Emulator integration tests - -The phone recording tests start the real foreground recording 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_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \ - "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest" -``` - -Standalone watch recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone: - -```shell -ANDROID_SERIAL=emulator-5554 ./gradlew :wear:connectedDebugAndroidTest \ - "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.WearSensorRecordingEmulatorTest" -``` - -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 recording archive. - -The phone-driven paired recording test runs instrumentation only on the phone. It discovers a real Wear sensor, starts and stops a watch recording over the production message protocol, asks Wear OS to sync measurements, and verifies the transferred metadata and sensor samples on the phone: - -```shell -PHONE_SERIAL=emulator-5554 WEAR_SERIAL=emulator-5556 \ - tools/emulator/run_phone_paired_recording_test.sh -``` - -No Firebase project, Maps key, secrets file, or external storage permission is required. - -## Dependencies - -The former Flipper, AppIntro, Material Dialogs, NumberPicker, Android About Page, LicensesDialog, Toasty, GraphView, and custom countdown modules have been removed. Their replacements are native APIs or small project-owned Compose components. - -[Vico](https://github.com/patrykandpatrick/vico) is retained as the sole feature-level third-party UI library because it provides a maintained, Compose-native chart model and renderer suitable for the live Wear OS plot. AndroidX, Google Play services for Wear/location, Kotlin coroutines, Hilt, and Detekt remain infrastructure dependencies. - -## Privacy - -Recordings are started by the user and remain visible through a foreground-service notification. SensorBox writes each device's measurement locally and does not include upload, analytics, or crash-reporting SDKs. - -## License +No Firebase project, Maps key, secrets file, or external-storage permission is required. -SensorBox is licensed under the Apache License 2.0. See [LICENSE](LICENSE). +SensorBox is licensed under the [Apache License 2.0](LICENSE). diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/GooglePlayWearConnectionRepository.kt similarity index 91% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/GooglePlayWearConnectionRepository.kt index 68c1a74..b5c4c53 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/GooglePlayWearConnectionRepository.kt @@ -1,14 +1,14 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection import android.content.Context import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.Node import com.google.android.gms.wearable.Wearable -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.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnection.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnection.kt similarity index 80% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnection.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnection.kt index e63d80e..698ce3e 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnection.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnection.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection data class WearNode(val id: String, val displayName: String, val isNearby: Boolean) diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionModule.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionModule.kt similarity index 87% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionModule.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionModule.kt index f4eb7c9..045ebd5 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionModule.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection import dagger.Binds import dagger.Module diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionRepository.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionRepository.kt similarity index 73% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionRepository.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionRepository.kt index 3862c80..c6b340d 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionRepository.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow interface WearConnectionRepository { diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionUseCases.kt similarity index 83% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionUseCases.kt index 8f9bbce..fed78af 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearConnectionUseCases.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearConnectionUseCases.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow import javax.inject.Inject diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelector.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelector.kt similarity index 75% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelector.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelector.kt index cd88586..13d18ba 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelector.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelector.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection internal object WearNodeSelector { fun select(nodes: Collection): WearNode? = diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/WearOsConstants.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearOsConstants.kt similarity index 81% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/WearOsConstants.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearOsConstants.kt index 0da0453..7480524 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/WearOsConstants.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearOsConstants.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib +package com.tomasrepcik.sensorbox.wearoslib.connection object WearOsConstants { const val PHONE_APP_CAPABILITY = "phone_app" diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchange.kt similarity index 95% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchange.kt index 5ae5f03..c093af1 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchange.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.wearoslib.protocol +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/SendWearCommandUseCase.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/SendWearCommandUseCase.kt similarity index 59% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/SendWearCommandUseCase.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/SendWearCommandUseCase.kt index cab02f8..56df2f8 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/SendWearCommandUseCase.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/SendWearCommandUseCase.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.wearoslib.protocol +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase import javax.inject.Inject class SendWearCommandUseCase @Inject constructor(private val sendMessage: SendWearMessageUseCase) { diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommand.kt similarity index 95% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommand.kt index 8da1224..7c39447 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommand.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.wearoslib.protocol +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodec.kt similarity index 93% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodec.kt index e429f75..4270429 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodec.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.wearoslib.protocol +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording -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 -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt similarity index 89% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt index e4d1a14..9ada4db 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt @@ -1,13 +1,13 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync import android.content.Context import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable -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.withAppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.withAppError import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt similarity index 73% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt index f37880e..bb4045a 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync import kotlinx.serialization.Serializable diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodec.kt similarity index 92% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodec.kt index d16262a..81825b6 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodec.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodec.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync -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 -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferClient.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferClient.kt similarity index 76% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferClient.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferClient.kt index ab21c9c..3b7405d 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferClient.kt @@ -1,7 +1,7 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync import com.google.android.gms.wearable.ChannelClient -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import java.io.InputStream interface WearFileTransferClient { diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferModule.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferModule.kt similarity index 88% rename from WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferModule.kt rename to WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferModule.kt index 0405de7..5ae1bfb 100644 --- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileTransferModule.kt +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileTransferModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync import dagger.Binds import dagger.Module diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelectorTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelectorTest.kt similarity index 93% rename from WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelectorTest.kt rename to WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelectorTest.kt index 3ab7f40..0103bf0 100644 --- a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/WearNodeSelectorTest.kt +++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/connection/WearNodeSelectorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection import org.junit.Assert.assertEquals import org.junit.Test diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchangeTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchangeTest.kt new file mode 100644 index 0000000..5181a79 --- /dev/null +++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchangeTest.kt @@ -0,0 +1,134 @@ +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingCommandExchangeTest { + @Test + fun `Given a recording result When a command is sent Then its outcome is returned`() = runTest { + // Given + val fixture = Fixture() + + // When + val result = fixture.exchange.send(fixture.startCommand()) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.repository.sendCalls) + } + + @Test + fun `Given a failed peer result When a command is sent Then peer diagnostics are retained`() = runTest { + // Given + val fixture = Fixture().apply { resultFails = true } + + // When + val result = fixture.exchange.send(fixture.startCommand()) + + // Then + assertEquals(AppErrorCode.PERMISSION, result.errorOrNull()?.code) + assertEquals("watch", result.errorOrNull()?.context?.get("source")) + assertEquals("fixture", result.errorOrNull()?.context?.get("missingPermissions")) + } + + @Test + fun `Given a lost result When a command is sent Then delivery retries and reports that it was sent`() = runTest { + // Given + val fixture = Fixture().apply { dropResults = true } + + // When + val result = fixture.exchange.send(fixture.startCommand()) + + // Then + assertEquals(3, fixture.repository.sendCalls) + assertEquals(AppErrorCode.TIMEOUT, result.errorOrNull()?.code) + assertEquals("true", result.errorOrNull()?.context?.get("commandWasSent")) + } + + @Test + fun `Given transport failures When a command is sent Then the last transport error is returned`() = runTest { + // Given + val fixture = Fixture().apply { sendFails = true } + + // When + val result = fixture.exchange.send(fixture.startCommand()) + + // Then + assertEquals(3, fixture.repository.sendCalls) + assertEquals(AppErrorCode.CONNECTIVITY, result.errorOrNull()?.code) + } + + private class Fixture { + val repository = ExchangeRepository() + val exchange = RecordingCommandExchange( + sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + peerCapability = "watch-capability", + peerPath = "/watch", + peerName = "watch", + ) + var dropResults = false + var resultFails = false + var sendFails: Boolean + get() = repository.sendFails + set(value) { + repository.sendFails = value + } + + init { + repository.onCommand = { command -> + if (!dropResults) exchange.receive(command.result(resultFails)) + } + } + + fun startCommand() = WearCommand.StartRecording( + sessionId = "session-123", + request = WearRecordingRequest("fixture", listOf(1), includesGps = false), + ) + + private fun WearCommand.result(failed: Boolean): WearCommand.RecordingResult { + val operation = when (this) { + is WearCommand.StartRecording -> WearRecordingOperation.START + is WearCommand.StopRecording -> WearRecordingOperation.STOP + else -> error("Fixture only accepts recording commands") + } + return WearCommand.RecordingResult( + sessionId = checkNotNull(recordingSessionId()), + operation = operation, + outcome = if (failed) WearRecordingOutcome.FAILED else WearRecordingOutcome.SUCCEEDED, + errorCode = AppErrorCode.PERMISSION.takeIf { failed }, + errorOperation = "Validate watch recording".takeIf { failed }, + errorMessage = "Fixture permission failure".takeIf { failed }, + errorContext = if (failed) mapOf("missingPermissions" to "fixture") else emptyMap(), + failureCount = if (failed) 1 else 0, + ) + } + } +} + +private class ExchangeRepository : WearConnectionRepository { + var sendCalls = 0 + var sendFails = false + var onCommand: (WearCommand) -> Unit = { } + + override fun observeCapability(capability: String): Flow = emptyFlow() + + override suspend fun findNode(capability: String): WearNode = WearNode("peer", "Peer", isNearby = true) + + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + sendCalls += 1 + if (sendFails) return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Send fixture command")) + onCommand(WearCommandCodec.decode(payload).getOrThrow()) + return AppResult.success(Unit) + } +} diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodecTest.kt similarity index 96% rename from WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt rename to WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodecTest.kt index 9033127..cfe19d9 100644 --- a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/WearCommandCodecTest.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.wearoslib.protocol +package com.tomasrepcik.sensorbox.wearoslib.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodecTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt similarity index 93% rename from WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodecTest.kt rename to WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt index df5631c..6405563 100644 --- a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodecTest.kt +++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/FakeWearConnectionRepository.kt b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connection/FakeWearConnectionRepository.kt similarity index 87% rename from WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/FakeWearConnectionRepository.kt rename to WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connection/FakeWearConnectionRepository.kt index c390ebd..6e7e39a 100644 --- a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connectivity/FakeWearConnectionRepository.kt +++ b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/connection/FakeWearConnectionRepository.kt @@ -1,7 +1,7 @@ -package com.tomasrepcik.sensorbox.wearoslib.connectivity +package com.tomasrepcik.sensorbox.wearoslib.connection -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearSyncEmulatorFixture.kt similarity index 98% rename from WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt rename to WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearSyncEmulatorFixture.kt index 49e7a31..cda2efa 100644 --- a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt +++ b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearSyncEmulatorFixture.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.wearoslib.files +package com.tomasrepcik.sensorbox.wearoslib.sync data class WearSyncFileFixture( val measurementName: String, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5cd3a6..f76cc34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.hilt.android) + implementation(libs.hilt.lifecycle.viewmodel.compose) ksp(libs.hilt.compiler) implementation(platform(libs.androidx.compose.bom)) @@ -88,6 +89,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) testImplementation(libs.junit) diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreenTest.kt similarity index 88% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreenTest.kt index 48c1979..dbbfceb 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreenTest.kt @@ -1,15 +1,17 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.tomasrepcik.sensorbox.about.OpenSourceLicense +import com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.settings.SettingsIntent +import com.tomasrepcik.sensorbox.settings.SettingsState import org.junit.Rule import org.junit.Test diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreenTest.kt similarity index 86% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreenTest.kt index fc471eb..b729a0d 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreenTest.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.design.SensorBoxTheme import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt similarity index 51% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt index 2d8c61b..dafefc9 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt @@ -1,40 +1,47 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements 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.junit4.v2.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 com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.measurements.details.MeasurementDetailsIntent +import com.tomasrepcik.sensorbox.measurements.details.MeasurementDetailsScreen +import com.tomasrepcik.sensorbox.measurements.details.MeasurementDetailsState +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsIntent +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsScreen +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsState +import com.tomasrepcik.sensorbox.measurements.preview.MeasurementPreviewIntent +import com.tomasrepcik.sensorbox.measurements.preview.MeasurementPreviewScreen +import com.tomasrepcik.sensorbox.measurements.preview.MeasurementPreviewState +import com.tomasrepcik.sensorbox.measurements.storage.GpsCoordinate +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementDetails +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileKind +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileSummary +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary +import com.tomasrepcik.sensorbox.measurements.storage.SensorSeriesSample import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test -class MeasurementBrowserScreenTest { +class MeasurementScreensTest { @get:Rule val composeRule = createComposeRule() @Test - fun givenArchiveWhenMeasurementIsTappedThenItsIdentifierIsSelected() { + fun givenMeasurementListWhenMeasurementIsTappedThenItsIdentifierIsSelected() { var selectedId: String? = null composeRule.setContent { SensorBoxTheme { MeasurementsScreen( - state = MeasurementBrowserState(measurements = listOf(summary)), + state = MeasurementsState(measurements = listOf(summary)), onIntent = { intent -> - if (intent is MeasurementBrowserIntent.OpenMeasurementDetails) { - selectedId = intent.measurementId - } + if (intent is MeasurementsIntent.OpenDetails) selectedId = intent.measurementId }, onBack = {}, ) @@ -46,14 +53,14 @@ class MeasurementBrowserScreenTest { } @Test - fun givenMeasurementDetailsThenAllMetadataAndFilesCanBeInspected() { + fun givenMeasurementDetailsThenMetadataAndFilesCanBeInspected() { var selectedFile: String? = null composeRule.setContent { SensorBoxTheme { MeasurementDetailsScreen( - state = MeasurementBrowserState(selectedMeasurement = details), + state = MeasurementDetailsState(details = details), onIntent = { intent -> - if (intent is MeasurementBrowserIntent.OpenMeasurementFile) selectedFile = intent.fileId + if (intent is MeasurementDetailsIntent.OpenFile) selectedFile = intent.fileId }, onBack = {}, ) @@ -68,9 +75,30 @@ class MeasurementBrowserScreenTest { } @Test - fun givenSensorSamplesThenTheirSeriesAndChartSummaryAreShown() { - var chartIntent: MeasurementBrowserIntent? = null - showFile( + fun givenLargeCsvWhenPreviewIsLoadingThenProgressIsShownOnThePreviewScreen() { + composeRule.setContent { + SensorBoxTheme { + MeasurementPreviewScreen( + state = MeasurementPreviewState( + file = sensorFile, + progress = 0.42f, + isLoading = true, + ), + onIntent = {}, + onBack = {}, + ) + } + } + + composeRule.onNodeWithText("Accelerometer").assertIsDisplayed() + composeRule.onNodeWithText("Loading measurement… 42%").assertIsDisplayed() + composeRule.onNodeWithText("2 chart samples").assertDoesNotExist() + } + + @Test + fun givenSensorSamplesThenPreviewShowsTheirSeriesAndChart() { + var chartIntent: MeasurementPreviewIntent? = null + showPreview( sensorFile, MeasurementFileContent.SensorSeries( columns = listOf("x", "y", "z"), @@ -80,6 +108,7 @@ class MeasurementBrowserScreenTest { ), truncated = false, ), + sensorMetadata, onIntent = { chartIntent = it }, ) @@ -88,17 +117,15 @@ class MeasurementBrowserScreenTest { composeRule.onNodeWithText("Bosch accelerometer").assertIsDisplayed() composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Zoom in")) 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) + assertEquals(MeasurementPreviewIntent.ZoomSensorChartTimeWindow(2f, 0.5f), chartIntent) } } @Test - fun givenGpsSamplesThenCoordinatesKeepNavigationPrecision() { - showFile( + fun givenGpsSamplesThenPreviewKeepsCoordinatePrecision() { + showPreview( gpsFile, MeasurementFileContent.GpsCoordinates( listOf(GpsCoordinate(1_725_000_000_000, 48.148596, 17.107748, 140.0, 3.0, null, null, "gps")), @@ -110,18 +137,19 @@ class MeasurementBrowserScreenTest { composeRule.onNodeWithText("48.148596 , 17.107748").assertIsDisplayed() } - private fun showFile( + private fun showPreview( file: MeasurementFileSummary, content: MeasurementFileContent, - onIntent: (MeasurementBrowserIntent) -> Unit = {}, + metadata: List = emptyList(), + onIntent: (MeasurementPreviewIntent) -> Unit = {}, ) { composeRule.setContent { SensorBoxTheme { - MeasurementFileScreen( - state = MeasurementBrowserState( - selectedMeasurement = details, - selectedFile = file, - selectedFileContent = content, + MeasurementPreviewScreen( + state = MeasurementPreviewState( + file = file, + content = content, + sensorMetadata = metadata, ), onIntent = onIntent, onBack = {}, @@ -134,16 +162,12 @@ class MeasurementBrowserScreenTest { 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 sensorMetadata = listOf(MeasurementMetadataEntry("sensor", "Bosch accelerometer")) val details = MeasurementDetails( summary, listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")), listOf(sensorFile, gpsFile), - sensorMetadataByFile = mapOf( - sensorFile.id to listOf( - MeasurementMetadataEntry("sensor", "Bosch accelerometer"), - MeasurementMetadataEntry("writtenSamples", "2"), - ), - ), + sensorMetadataByFile = mapOf(sensorFile.id to sensorMetadata), ) } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt similarity index 93% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt index 334d158..b47786d 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt @@ -1,12 +1,12 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.measurements.sync 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 com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncEmulatorFixture +import com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncFileFixture import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertTrue import org.junit.Before diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenRobot.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenRobot.kt similarity index 95% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenRobot.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenRobot.kt index e7d21ac..0ffd641 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenRobot.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenRobot.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -11,7 +11,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 com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import com.tomasrepcik.sensorbox.design.SensorBoxTheme class OnboardingScreenRobot(private val rule: ComposeContentTestRule) { var lastIntent: OnboardingIntent? = null diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenTest.kt similarity index 97% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenTest.kt index fe75630..d32a410 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreenTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.compose.ui.test.junit4.v2.createComposeRule import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhonePairedRecordingEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhonePairedRecordingEmulatorTest.kt new file mode 100644 index 0000000..0149094 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhonePairedRecordingEmulatorTest.kt @@ -0,0 +1,250 @@ +package com.tomasrepcik.sensorbox.pairedrecording + +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +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.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Wearable +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.storage.NativeDocumentStorage +import com.tomasrepcik.sensorbox.measurements.sync.ReceiveWatchFileUseCase +import com.tomasrepcik.sensorbox.measurements.sync.WatchFileDestination +import com.tomasrepcik.sensorbox.wearoslib.connection.GooglePlayWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason +import com.tomasrepcik.sensorbox.wearoslib.sync.GooglePlayWearFileTransferClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class PhonePairedRecordingEmulatorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun givenPairedEmulatorsWhenPhoneRecordsOnWearAndSyncsThenPhoneReceivesRealMeasurement() = runBlocking { + // Given + assumeFalse( + "Paired recording test must run on the phone emulator", + context.packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH), + ) + val measurementName = "000_PHONE_DRIVEN_WEAR_${System.currentTimeMillis()}" + val sessionId = "phone-driven-wear-${System.currentTimeMillis()}" + val receivedDirectory = File(context.filesDir, "SensorBox/WEAR_$measurementName") + receivedDirectory.deleteRecursively() + val peer = PairedWearRecordingClient(context) + peer.open() + + try { + val accelerometer = peer.availableSensors() + .firstOrNull { sensor -> sensor.type == Sensor.TYPE_ACCELEROMETER } + ?: throw AssertionError("Wear emulator has no accelerometer") + + // When + peer.start( + sessionId = sessionId, + request = WearRecordingRequest( + folderName = measurementName, + sensorIds = listOf(accelerometer.type), + includesGps = false, + durationMillis = RECORDING_MILLIS, + ), + ).getOrThrow() + peer.awaitAutomaticStop(sessionId) + peer.sync().getOrThrow() + val receivedFiles = awaitReceivedFiles(receivedDirectory, peer) + + // Then + assertMeasurementMetadata( + file = receivedFiles.getValue(METADATA_FILE_NAME), + measurementName = measurementName, + sensor = accelerometer, + ) + assertAccelerometerSamples(receivedFiles.getValue(ACCELEROMETER_FILE_NAME)) + } finally { + peer.close() + } + } + + private suspend fun awaitReceivedFiles(directory: File, peer: PairedWearRecordingClient): Map { + repeat(FILE_WAIT_ATTEMPTS) { + peer.throwIfTransferFailed() + val files = directory.listFiles().orEmpty().associateBy(File::getName) + val metadata = files[METADATA_FILE_NAME] + val accelerometer = files[ACCELEROMETER_FILE_NAME] + if ( + metadata?.length()?.let { length -> length > 0L } == true && + accelerometer?.readLines()?.size?.let { rows -> rows >= MINIMUM_CSV_ROWS } == true + ) { + return files + } + delay(FILE_WAIT_INTERVAL_MILLIS) + } + peer.throwIfTransferFailed() + throw AssertionError( + "Timed out waiting for transferred Wear measurement in $directory. " + + "Received ${directory.listFiles().orEmpty().map(File::getName)}", + ) + } + + private fun assertMeasurementMetadata(file: File, measurementName: String, sensor: WearSensorInfo) { + val metadata = JSONObject(file.readText()) + assertEquals(measurementName, metadata.getString("folder")) + val recordedTypes = metadata.getJSONArray("ranges").let { ranges -> + buildSet { + repeat(ranges.length()) { index -> add(ranges.getJSONObject(index).getInt("type")) } + } + } + assertEquals(setOf(sensor.type), recordedTypes) + } + + private fun assertAccelerometerSamples(file: File) { + val rows = file.readLines() + assertTrue("Expected transferred Wear samples in $file, got $rows", rows.size >= MINIMUM_CSV_ROWS) + assertEquals(ACCELEROMETER_HEADER, rows.first()) + val timestamps = rows.drop(1).map { row -> + val columns = row.split(';') + assertEquals("Malformed transferred Wear row: $row", ACCELEROMETER_COLUMN_COUNT, columns.size) + assertTrue("Non-numeric transferred Wear row: $row", columns.all { value -> value.toDoubleOrNull() != null }) + columns.first().toLong() + } + assertEquals(timestamps.sorted(), timestamps) + } + + private companion object { + const val RECORDING_MILLIS = 3_000L + const val FILE_WAIT_ATTEMPTS = 120 + const val FILE_WAIT_INTERVAL_MILLIS = 500L + const val MINIMUM_CSV_ROWS = 2 + const val METADATA_FILE_NAME = "extra.json" + const val ACCELEROMETER_FILE_NAME = "accelerometer.csv" + const val ACCELEROMETER_HEADER = "t_sensor;x;y;z;accuracy" + const val ACCELEROMETER_COLUMN_COUNT = 5 + } +} + +private class PairedWearRecordingClient(context: Context) { + private val incomingCommands = Channel(Channel.UNLIMITED) + private val transferResults = Channel>(Channel.UNLIMITED) + private val transferScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val receiveWatchFile = ReceiveWatchFileUseCase( + transferClient = GooglePlayWearFileTransferClient(context), + destination = WatchFileDestination(context, NativeDocumentStorage(context)), + ) + private val sendCommand = SendWearCommandUseCase( + SendWearMessageUseCase(GooglePlayWearConnectionRepository(context)), + ) + private val recordingExchange = RecordingCommandExchange( + sendCommand = sendCommand, + peerCapability = WEAR_APP_CAPABILITY, + peerPath = WEAR_MESSAGE_PATH, + peerName = "watch", + ) + private val messageClient = Wearable.getMessageClient(context) + private val channelClient = Wearable.getChannelClient(context) + private val listener = MessageClient.OnMessageReceivedListener { event -> + if (event.path != PHONE_MESSAGE_PATH) return@OnMessageReceivedListener + when (val command = WearCommandCodec.decode(event.data).getOrNull()) { + is WearCommand.RecordingResult -> recordingExchange.receive(command) + null -> Unit + else -> incomingCommands.trySend(command) + } + } + private val channelCallback = object : ChannelClient.ChannelCallback() { + override fun onChannelOpened(channel: ChannelClient.Channel) { + transferScope.launch { transferResults.send(receiveWatchFile(channel)) } + } + } + + fun open() { + Tasks.await(messageClient.addListener(listener)) + Tasks.await(channelClient.registerChannelCallback(channelCallback)) + } + + fun close() { + Tasks.await(channelClient.unregisterChannelCallback(channelCallback)) + Tasks.await(messageClient.removeListener(listener)) + transferScope.cancel() + transferResults.close() + incomingCommands.close() + } + + fun throwIfTransferFailed() { + while (true) { + val result = transferResults.tryReceive().getOrNull() ?: return + result.getOrThrow() + } + } + + suspend fun availableSensors(): List { + var lastFailure: AppError? = null + repeat(CATALOG_ATTEMPTS) { + when (val sent = sendOneWay(WearCommand.RequestAvailableSensors)) { + is AppResult.Failure -> lastFailure = sent.error + is AppResult.Success -> { + val catalog = withTimeoutOrNull(CATALOG_ATTEMPT_TIMEOUT_MILLIS) { + incomingCommands.receiveAsFlow().filterIsInstance().first() + } + if (catalog != null) return catalog.sensors + } + } + } + throw AssertionError("Wear sensor catalog did not arrive: $lastFailure", lastFailure?.cause) + } + + suspend fun start(sessionId: String, request: WearRecordingRequest): AppResult = + recordingExchange.send(WearCommand.StartRecording(sessionId, request)) + + suspend fun awaitAutomaticStop(sessionId: String) { + val stopped = withTimeoutOrNull(AUTOMATIC_STOP_TIMEOUT_MILLIS) { + incomingCommands.receiveAsFlow() + .filterIsInstance() + .first { command -> command.sessionId == sessionId } + } + if (stopped == null) throw AssertionError("Wear recording $sessionId did not stop automatically") + if (stopped.reason != WearStopReason.DURATION_EXPIRED) { + throw AssertionError("Wear recording $sessionId stopped because ${stopped.reason}") + } + } + + suspend fun sync(): AppResult = sendOneWay(WearCommand.SyncMeasurements) + + private suspend fun sendOneWay(command: WearCommand): AppResult = + sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command) + + private companion object { + const val CATALOG_ATTEMPTS = 6 + const val CATALOG_ATTEMPT_TIMEOUT_MILLIS = 5_000L + const val AUTOMATIC_STOP_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearDiscoveryEmulatorTest.kt similarity index 88% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearDiscoveryEmulatorTest.kt index 19d1526..9e38604 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearDiscoveryEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearDiscoveryEmulatorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.pairedrecording import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -6,16 +6,16 @@ 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.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.wearoslib.connection.GooglePlayWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOperation +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filterIsInstance diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearVisibilityEmulatorTest.kt similarity index 89% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearVisibilityEmulatorTest.kt index a8b1e79..6aefe51 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneWearVisibilityEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWearVisibilityEmulatorTest.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.pairedrecording 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 com.tomasrepcik.sensorbox.bootstrap.MainActivity import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/PhoneSensorRecordingEmulatorTest.kt similarity index 88% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/PhoneSensorRecordingEmulatorTest.kt index d930c3c..3f32e75 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/PhoneSensorRecordingEmulatorTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/PhoneSensorRecordingEmulatorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.hardware.Sensor @@ -54,6 +54,27 @@ class PhoneSensorRecordingEmulatorTest { .assertRecorded() } + @Test + fun givenEveryPhoneSourceWhenRecordingStartsThenSourcesLaunchWithoutBlockingTheSession() { + fixture.start( + RecordingScenario( + name = "PHONE_ALL_SOURCES_RECORDING_TEST", + sensors = setOf(ACCELEROMETER, GYROSCOPE), + includesGps = true, + stopOnLowBattery = true, + useWakeLock = true, + gpsIntervalSeconds = 1, + gpsMinDistanceMeters = 0, + activityRecognition = true, + activityRecognitionPeriodSeconds = 1, + significantMotion = true, + ), + ) + .waitFor(1_000) + .stop() + .assertRecorded() + } + @Test fun givenATimedRecordingWhenDurationExpiresThenSamplesAreFinalized() { fixture.start( diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingEmulatorFixture.kt similarity index 98% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingEmulatorFixture.kt index fea4258..f31031d 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingEmulatorFixture.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.Manifest import android.content.Context @@ -10,9 +10,9 @@ 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.RecordingIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingServiceManifestTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingServiceManifestTest.kt new file mode 100644 index 0000000..3254883 --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/RecordingServiceManifestTest.kt @@ -0,0 +1,28 @@ +package com.tomasrepcik.sensorbox.recording + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RecordingServiceManifestTest { + @Test + fun givenInstalledPhoneAppWhenRecordingStartsThenRecordingServiceIsRegistered() { + // Given + val context = ApplicationProvider.getApplicationContext() + val component = ComponentName(context, RecordingService::class.java) + + // When + @Suppress("DEPRECATION") + val service = context.packageManager.getServiceInfo(component, PackageManager.GET_META_DATA) + + // Then + assertEquals(RecordingService::class.java.name, service.name) + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenRobot.kt similarity index 88% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenRobot.kt index 53c1782..53a78cd 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenRobot.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenRobot.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule @@ -6,15 +6,16 @@ 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 +import com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor class RecordScreenRobot(private val rule: ComposeContentTestRule) { fun givenRecordScreen( selected: Boolean = false, gpsSelected: Boolean = false, watchConnected: Boolean = false, - onIntent: (RecordingIntent) -> Unit = {}, + onIntent: (RecordIntent) -> Unit = {}, ) = apply { rule.setContent { SensorBoxTheme { diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenTest.kt similarity index 71% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenTest.kt index 75c3bd3..6ac48b8 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/active/RecordScreenTest.kt @@ -1,6 +1,7 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.tomasrepcik.sensorbox.recording.RecordingDevice import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -11,63 +12,63 @@ class RecordScreenTest { @Test fun givenRecordScreenWhenSensorIsTappedThenToggleIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .thenRecordingButtonIsVisible() .whenAccelerometerIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.ToggleSensor(1), actualIntent) + assertEquals(RecordIntent.ToggleSensor(1), actualIntent) } } @Test fun givenRecordScreenWhenSensorInfoIsTappedThenDetailsIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenAccelerometerInfoIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.OpenSensorDetails(1), actualIntent) + assertEquals(RecordIntent.OpenSensorDetails(1), actualIntent) } } @Test fun givenSelectedSensorWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(selected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.OpenRecordingSetup, actualIntent) + assertEquals(RecordIntent.OpenRecordingSetup, actualIntent) } } @Test fun givenRecordScreenWhenGpsIsTappedThenToggleGpsIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenGpsIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.ToggleGps, actualIntent) + assertEquals(RecordIntent.ToggleGps, actualIntent) } } @Test fun givenWearSensorWhenInfoIsTappedThenWearDetailsIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(watchConnected = true) { actualIntent = it } .whenWearAccelerometerInfoIsTapped() composeRule.runOnIdle { assertEquals( - RecordingIntent.OpenSensorDetails(1, RecordingDevice.WATCH), + RecordIntent.OpenSensorDetails(1, RecordingDevice.WATCH), actualIntent, ) } @@ -75,13 +76,13 @@ class RecordScreenTest { @Test fun givenOnlyGpsSelectedWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(gpsSelected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.OpenRecordingSetup, actualIntent) + assertEquals(RecordIntent.OpenRecordingSetup, actualIntent) } } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreenTest.kt similarity index 88% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreenTest.kt index c4ab04d..51d11bf 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreenTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasScrollAction @@ -6,9 +6,11 @@ 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 com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.SensorReportingMode import org.junit.Rule import org.junit.Test diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenRobot.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenRobot.kt similarity index 75% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenRobot.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenRobot.kt index 25df53d..4982006 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenRobot.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenRobot.kt @@ -1,14 +1,15 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor class RecordingSetupScreenRobot(private val rule: ComposeContentTestRule) { - fun givenRecordingSetup(onIntent: (RecordingIntent) -> Unit = {}) = apply { + fun givenRecordingSetup(onIntent: (RecordingSetupIntent) -> Unit = {}) = apply { rule.setContent { SensorBoxTheme { RecordingSetupScreen( @@ -18,6 +19,7 @@ class RecordingSetupScreenRobot(private val rule: ComposeContentTestRule) { recordingArchivePath = "Fixture/SensorBox", ), onIntent = onIntent, + onBack = {}, ) } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenTest.kt similarity index 79% rename from app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenTest.kt rename to app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenTest.kt index fb284e8..1269c6f 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreenTest.kt @@ -1,9 +1,12 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup 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 com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.recording.RecordingMessage +import com.tomasrepcik.sensorbox.recording.RecordingMessageText +import com.tomasrepcik.sensorbox.recording.RecordingState import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -14,14 +17,14 @@ class RecordingSetupScreenTest { @Test fun givenConfiguredSetupWhenStartIsTappedThenRecordingRequestIsSent() { - var actualIntent: RecordingIntent? = null + var actualIntent: RecordingSetupIntent? = null RecordingSetupScreenRobot(composeRule) .givenRecordingSetup { actualIntent = it } .thenFolderAndSettingsAreVisible() .whenStartRecordingIsTapped() composeRule.runOnIdle { - assertEquals(RecordingIntent.StartRecording, actualIntent) + assertEquals(RecordingSetupIntent.StartRecording, actualIntent) } } @@ -50,6 +53,7 @@ class RecordingSetupScreenTest { isStarting = true, ), onIntent = { }, + onBack = {}, ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62e169e..8635e6e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ - + @@ -44,7 +44,7 @@ diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/AboutDialogBody.kt similarity index 95% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/AboutDialogBody.kt index b09a53a..4c237b7 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/AboutDialogBody.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/AboutScreen.kt similarity index 96% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/AboutScreen.kt index 3b7e154..cf8799e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/AboutScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseComponents.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseComponents.kt index 1152588..4ce2c74 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense +import com.tomasrepcik.sensorbox.about.OpenSourceLicense @Composable internal fun OpenSourceLicenseRow(name: String, onClick: () -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseRepository.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseRepository.kt index 1382396..67e0e02 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicenseRepository.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.domain.licenses +package com.tomasrepcik.sensorbox.about import android.content.Context import com.tomasrepcik.sensorbox.R -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.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreen.kt similarity index 79% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreen.kt index 8348f05..a190671 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/OpenSourceLicensesScreen.kt @@ -1,11 +1,14 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense +import com.tomasrepcik.sensorbox.about.OpenSourceLicense +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.settings.SettingsIntent +import com.tomasrepcik.sensorbox.settings.SettingsState @Composable fun OpenSourceLicensesScreen( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PrivacyScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/about/PrivacyScreen.kt similarity index 90% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PrivacyScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/about/PrivacyScreen.kt index 1a0077b..e1f1e4b 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PrivacyScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/about/PrivacyScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.about import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen @Composable fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt deleted file mode 100644 index 0876cd0..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ /dev/null @@ -1,259 +0,0 @@ -package com.tomasrepcik.sensorbox.activities - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.provider.Settings -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tomasrepcik.sensorbox.R -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.domain.recording.RecordingArchiveSelection -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 -import com.tomasrepcik.sensorbox.presentation.main.RecordingViewModel -import com.tomasrepcik.sensorbox.presentation.main.SensorBoxApp -import com.tomasrepcik.sensorbox.presentation.main.SettingsEffect -import com.tomasrepcik.sensorbox.presentation.main.SettingsViewModel -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -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 recordingArchiveRequestOwner = RecordingArchiveRequestOwner.RECORDING - private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val selection = if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> - RecordingArchiveSelection.Selected(uri.toString(), it.data?.flags ?: 0) - } ?: RecordingArchiveSelection.Cancelled - } else { - RecordingArchiveSelection.Cancelled - } - when (recordingArchiveRequestOwner) { - RecordingArchiveRequestOwner.ONBOARDING -> onboardingViewModel.handleRecordingArchiveResult(selection) - RecordingArchiveRequestOwner.RECORDING -> recordingViewModel.handleRecordingArchiveResult(selection) - } - } - private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - recordingViewModel.handlePermissionResult() - } - private val locationPreviewPermissionRequest = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - recordingViewModel.handleLocationPreviewPermissionResult() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) mainViewModel.showPrivacyRationale() - setContent { - 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) - } - LaunchedEffect(recordingViewModel) { - recordingViewModel.effects.collect(::handleRecordingEffect) - } - LaunchedEffect(settingsViewModel) { - settingsViewModel.effects.collect(::handleSettingsEffect) - } - LaunchedEffect(measurementBrowserViewModel) { - measurementBrowserViewModel.effects.collect(::handleMeasurementBrowserEffect) - } - SensorBoxTheme( - themeMode = settingsState.preferences.display.themeMode, - dynamicColor = settingsState.preferences.display.dynamicColors, - ) { - SensorBoxApp( - 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, - onDismissFailure = mainViewModel::dismissFailure, - ) - } - } - } - - private fun handleOnboardingEffect(effect: OnboardingEffect) { - when (effect) { - OnboardingEffect.PickRecordingArchive -> openRecordingArchivePicker(RecordingArchiveRequestOwner.ONBOARDING) - - OnboardingEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) - - OnboardingEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) - - OnboardingEffect.RequestBatteryOptimizationExemption -> - requestBatteryOptimizationExemption(onboardingViewModel::reportFailure) - - is OnboardingEffect.Navigate -> mainViewModel.navigate(effect.route) - } - } - - private fun handleRecordingEffect(effect: RecordingEffect) { - when (effect) { - RecordingEffect.PickRecordingArchive -> openRecordingArchivePicker(RecordingArchiveRequestOwner.RECORDING) - - is RecordingEffect.Navigate -> mainViewModel.navigate(effect.route) - - is RecordingEffect.RequestPermissions -> appResult(AppErrorCode.PERMISSION, "Request app permissions") { - permissionRequest.launch(effect.permissions.toTypedArray()) - }.onFailure(recordingViewModel::reportFailure) - - RecordingEffect.RequestLocationPreviewPermission -> appResult( - AppErrorCode.PERMISSION, - "Request location preview permission", - ) { - locationPreviewPermissionRequest.launch( - arrayOf( - android.Manifest.permission.ACCESS_FINE_LOCATION, - android.Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - }.onFailure(recordingViewModel::reportFailure) - } - } - - private fun handleSettingsEffect(effect: SettingsEffect) { - when (effect) { - SettingsEffect.RequestBatteryOptimizationExemption -> - requestBatteryOptimizationExemption(settingsViewModel::reportFailure) - - is SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText(effect.text) - - is SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile(effect) - - is SettingsEffect.CopyDiagnosticsText -> copyDiagnostics(effect.text) - - SettingsEffect.DiagnosticsCleared -> showToast(R.string.diagnostics_cleared) - - is SettingsEffect.Navigate -> mainViewModel.navigate(effect.route) - } - } - - private fun handleMeasurementBrowserEffect(effect: MeasurementBrowserEffect) { - when (effect) { - is MeasurementBrowserEffect.Navigate -> mainViewModel.navigate(effect.route) - } - } - - private fun openRecordingArchivePicker(owner: RecordingArchiveRequestOwner) { - recordingArchiveRequestOwner = owner - appResult(AppErrorCode.EXTERNAL_ACTION, "Open recording archive picker") { - directoryPicker.launch(recordingArchiveIntent()) - }.onFailure(::reportRecordingArchivePickerFailure) - } - - private fun openWebPage(url: String) { - launchExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "Open web page") - .onFailure(onboardingViewModel::reportFailure) - } - - @SuppressLint("BatteryLife") - private fun requestBatteryOptimizationExemption( - reportFailure: (com.tomasrepcik.sensorbox.core.error.AppError) -> Unit, - ) { - val intent = Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:$packageName"), - ) - launchExternalIntent(intent, "Request battery optimization exemption").onFailure { - launchExternalIntent( - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), - "Open battery optimization settings", - ).onFailure(reportFailure) - } - } - - private fun shareDiagnosticsText(text: String) { - val intent = Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) - .putExtra(Intent.EXTRA_TEXT, text) - launchShareIntent(intent).onFailure(settingsViewModel::reportFailure) - } - - private fun shareDiagnosticsFile(effect: SettingsEffect.ShareDiagnosticsFile) { - val uri = Uri.parse(effect.contentUri) - val intent = Intent(Intent.ACTION_SEND) - .setType(effect.mimeType) - .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.clipData = ClipData.newUri(contentResolver, effect.displayName, uri) - launchShareIntent(intent).onFailure(settingsViewModel::reportFailure) - } - - private fun launchShareIntent(intent: Intent): AppResult = - launchExternalIntent(Intent.createChooser(intent, getString(R.string.diagnostics_share)), "Share diagnostics") - - private fun launchExternalIntent(intent: Intent, operation: String): AppResult = appResult( - AppErrorCode.EXTERNAL_ACTION, - operation, - ) { - startActivity(intent) - } - - private fun copyDiagnostics(text: String) { - appResult(AppErrorCode.EXTERNAL_ACTION, "Copy diagnostics") { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText(getString(R.string.diagnostics_title), text), - ) - }.fold( - onSuccess = { showToast(R.string.diagnostics_copied) }, - onFailure = settingsViewModel::reportFailure, - ) - } - - private fun reportRecordingArchivePickerFailure(error: com.tomasrepcik.sensorbox.core.error.AppError) { - when (recordingArchiveRequestOwner) { - RecordingArchiveRequestOwner.ONBOARDING -> onboardingViewModel.reportFailure(error) - RecordingArchiveRequestOwner.RECORDING -> recordingViewModel.reportFailure(error) - } - } - - private fun showToast(message: Int) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - } - - private fun recordingArchiveIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) - } - - private enum class RecordingArchiveRequestOwner { - ONBOARDING, - RECORDING, - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/CoroutineModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/CoroutineModule.kt similarity index 91% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/CoroutineModule.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/CoroutineModule.kt index 112e686..5f03329 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/CoroutineModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/CoroutineModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.bootstrap import dagger.Module import dagger.Provides diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt new file mode 100644 index 0000000..3e2afd5 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt @@ -0,0 +1,20 @@ +package com.tomasrepcik.sensorbox.bootstrap + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.tomasrepcik.sensorbox.navigation.SensorBoxRoot +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SensorBoxRoot( + showPrivacyRationale = intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE, + ) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/SensorBoxApp.kt b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/SensorBoxApp.kt similarity index 73% rename from app/src/main/java/com/tomasrepcik/sensorbox/SensorBoxApp.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/SensorBoxApp.kt index d763387..35335f6 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/SensorBoxApp.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/SensorBoxApp.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.bootstrap import android.app.Application -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics -import com.tomasrepcik.sensorbox.domain.paired.PhoneRecordingSessionObserver +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.pairedrecording.PhoneRecordingSessionObserver import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/design/NumberPickerSetting.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/design/NumberPickerSetting.kt index a0d4622..893bfc7 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/design/NumberPickerSetting.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.design import android.widget.NumberPicker import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxButtons.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxButtons.kt index eac7549..ade2dee 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxButtons.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxScreenComponents.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxScreenComponents.kt index 59bca8b..c62c79b 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxScreenComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxSettingsComponents.kt similarity index 94% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxSettingsComponents.kt index 75cd5a0..bc43e7c 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxSettingsComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,7 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.sensorIconResource @Composable fun SensorIdentity(sensor: SensorDescriptor, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/ui/theme/SensorBoxTheme.kt b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxTheme.kt similarity index 99% rename from app/src/main/java/com/tomasrepcik/sensorbox/ui/theme/SensorBoxTheme.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxTheme.kt index 4dc4051..0e81100 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/ui/theme/SensorBoxTheme.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/design/SensorBoxTheme.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.ui.theme +package com.tomasrepcik.sensorbox.design import android.app.Activity import android.os.Build diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreen.kt similarity index 90% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreen.kt index 4a8dc0e..b204e60 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/AppErrorScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -13,7 +13,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.design.SensorBoxPrimaryButton +import com.tomasrepcik.sensorbox.navigation.FullScreen @Composable internal fun AppErrorScreen(errorCode: AppErrorCode, onDismiss: () -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsLogScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsLogScreen.kt similarity index 80% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsLogScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsLogScreen.kt index 60e7fad..1435b4f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsLogScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsLogScreen.kt @@ -1,23 +1,18 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.settings.SettingsState @Composable -fun DiagnosticsLogScreen( - state: SettingsState, - onBack: () -> Unit, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) { - LaunchedEffect(Unit) { onRefresh() } +fun DiagnosticsLogScreen(state: SettingsState, onBack: () -> Unit, modifier: Modifier = Modifier) { SensorBoxBackScreen( title = stringResource(R.string.diagnostics_logs_title), onBack = onBack, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt similarity index 76% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt index 517a3a1..c8f2483 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.diagnostics import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.CompositeDiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticMetadata -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics -import com.tomasrepcik.sensorbox.core.error.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.CompositeDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.DiagnosticMetadata +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.core.diagnostics.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsShareFilePreparer.kt similarity index 84% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsShareFilePreparer.kt index 666646f..ba62985 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsShareFilePreparer.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.domain.diagnostics +package com.tomasrepcik.sensorbox.diagnostics import android.content.Context import androidx.core.content.FileProvider -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.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementComponents.kt similarity index 86% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementComponents.kt index 031a0d3..8ac9fc8 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,16 +15,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -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.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileKind +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileSummary +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry @Composable -internal fun MeasurementLoading() { +internal fun MeasurementLoading(modifier: Modifier = Modifier) { Column( - modifier = Modifier.fillMaxWidth().padding(32.dp), + modifier = modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), ) { CircularProgressIndicator() Text(stringResource(R.string.loading_measurements)) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementsComponents.kt similarity index 68% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementsComponents.kt index 7f04ecf..ef382ff 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementsComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,18 +10,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsIntent +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsState +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary -internal fun LazyListScope.measurementArchiveItems( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, -) { +internal fun LazyListScope.measurementArchiveItems(state: MeasurementsState, onIntent: (MeasurementsIntent) -> Unit) { when { - state.isLoading && state.measurements.isEmpty() -> item { MeasurementLoading() } + state.isLoading && state.measurements.isEmpty() -> item { + MeasurementLoading(Modifier.fillParentMaxHeight()) + } state.errorCode != null -> item { ArchiveError() } @@ -30,7 +32,7 @@ internal fun LazyListScope.measurementArchiveItems( else -> state.measurements.forEach { measurement -> item { MeasurementRow(measurement) { - onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(measurement.id)) + onIntent(MeasurementsIntent.OpenDetails(measurement.id)) } } } @@ -54,7 +56,11 @@ private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) Text(recordedAt, color = MaterialTheme.colorScheme.onSurfaceVariant) } Text( - stringResource(R.string.measurement_file_count, measurement.fileCount), + pluralStringResource( + R.plurals.measurement_file_count, + measurement.fileCount, + measurement.fileCount, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, ) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsComponents.kt similarity index 77% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsComponents.kt index fe27169..38cfd07 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.details import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -9,12 +9,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsSection +import com.tomasrepcik.sensorbox.measurements.components.DetailValueRow +import com.tomasrepcik.sensorbox.measurements.components.MeasurementFileRow +import com.tomasrepcik.sensorbox.measurements.components.MetadataRow +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementDetails internal fun LazyListScope.measurementDetailsItems( details: MeasurementDetails, - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, + onIntent: (MeasurementDetailsIntent) -> Unit, ) { item { SensorBoxSettingsSection(stringResource(R.string.measurement_summary)) } item { MeasurementSummaryRows(details) } @@ -29,11 +32,9 @@ internal fun LazyListScope.measurementDetailsItems( 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)) } } + item { MeasurementFileRow(file) { onIntent(MeasurementDetailsIntent.OpenFile(file.id)) } } } } - if (state.isLoading) item { MeasurementLoading() } - if (state.errorCode != null) item { ArchiveError() } } @Composable diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsContract.kt new file mode 100644 index 0000000..616c7ad --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsContract.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.measurements.details + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementDetails + +data class MeasurementDetailsState( + val details: MeasurementDetails? = null, + val isLoading: Boolean = false, + val errorCode: AppErrorCode? = null, +) + +sealed interface MeasurementDetailsIntent { + data class Load(val measurementId: String) : MeasurementDetailsIntent + data class OpenFile(val fileId: String) : MeasurementDetailsIntent +} + +sealed interface MeasurementDetailsEffect { + data class OpenFile(val measurementId: String, val fileId: String) : MeasurementDetailsEffect +} + +internal sealed interface MeasurementDetailsResult { + data object Loading : MeasurementDetailsResult + data class Loaded(val details: MeasurementDetails) : MeasurementDetailsResult + data class LoadFailed(val errorCode: AppErrorCode) : MeasurementDetailsResult +} + +internal object MeasurementDetailsReducer { + fun reduce(result: MeasurementDetailsResult): MeasurementDetailsState = when (result) { + MeasurementDetailsResult.Loading -> MeasurementDetailsState(isLoading = true) + is MeasurementDetailsResult.Loaded -> MeasurementDetailsState(details = result.details) + is MeasurementDetailsResult.LoadFailed -> MeasurementDetailsState(errorCode = result.errorCode) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsRoot.kt new file mode 100644 index 0000000..ae75f5c --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsRoot.kt @@ -0,0 +1,30 @@ +package com.tomasrepcik.sensorbox.measurements.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun MeasurementDetailsRoot( + measurementId: String, + onOpenFile: (measurementId: String, fileId: String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: MeasurementDetailsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is MeasurementDetailsEffect.OpenFile -> onOpenFile(effect.measurementId, effect.fileId) + } + } + } + LaunchedEffect(viewModel, measurementId) { + viewModel.accept(MeasurementDetailsIntent.Load(measurementId)) + } + MeasurementDetailsScreen(state, viewModel::accept, onBack, modifier) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsScreen.kt new file mode 100644 index 0000000..1a8e2fd --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsScreen.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.measurements.details + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.measurements.components.ArchiveError +import com.tomasrepcik.sensorbox.measurements.components.MeasurementLoading + +@Composable +fun MeasurementDetailsScreen( + state: MeasurementDetailsState, + onIntent: (MeasurementDetailsIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SensorBoxBackScreen( + title = stringResource(R.string.measurement_details), + onBack = onBack, + modifier = modifier, + itemSpacing = 0.dp, + ) { + when { + state.isLoading -> item { MeasurementLoading(Modifier.fillParentMaxHeight()) } + state.errorCode != null -> item { ArchiveError() } + state.details != null -> measurementDetailsItems(state.details, onIntent) + else -> item { ArchiveError() } + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModel.kt new file mode 100644 index 0000000..d48bb00 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModel.kt @@ -0,0 +1,60 @@ +package com.tomasrepcik.sensorbox.measurements.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.measurements.storage.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 MeasurementDetailsViewModel @Inject constructor( + private val repository: MeasurementRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(MeasurementDetailsState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + fun accept(intent: MeasurementDetailsIntent) { + when (intent) { + is MeasurementDetailsIntent.Load -> load(intent.measurementId) + is MeasurementDetailsIntent.OpenFile -> openFile(intent.fileId) + } + } + + private fun load(measurementId: String) { + viewModelScope.launch { + mutableState.value = MeasurementDetailsReducer.reduce(MeasurementDetailsResult.Loading) + repository.loadMeasurementDetails(measurementId).fold( + onSuccess = { details -> + mutableState.value = MeasurementDetailsReducer.reduce(MeasurementDetailsResult.Loaded(details)) + }, + onFailure = { error -> + mutableState.value = MeasurementDetailsReducer.reduce( + MeasurementDetailsResult.LoadFailed(error.code), + ) + appFailures.show(error) + }, + ) + } + } + + private fun openFile(fileId: String) { + val details = state.value.details ?: return + val file = details.files.firstOrNull { it.id == fileId } ?: return + mutableEffects.trySend( + MeasurementDetailsEffect.OpenFile( + measurementId = details.summary.id, + fileId = file.id, + ), + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsContract.kt new file mode 100644 index 0000000..e366002 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsContract.kt @@ -0,0 +1,39 @@ +package com.tomasrepcik.sensorbox.measurements.list + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary + +data class MeasurementsState( + val measurements: List = emptyList(), + val isLoading: Boolean = false, + val errorCode: AppErrorCode? = null, +) + +sealed interface MeasurementsIntent { + data object Refresh : MeasurementsIntent + data class OpenDetails(val measurementId: String) : MeasurementsIntent +} + +sealed interface MeasurementsEffect { + data class OpenDetails(val measurementId: String) : MeasurementsEffect +} + +internal sealed interface MeasurementsResult { + data object Loading : MeasurementsResult + data class Loaded(val measurements: List) : MeasurementsResult + data class LoadFailed(val errorCode: AppErrorCode) : MeasurementsResult +} + +internal object MeasurementsReducer { + fun reduce(state: MeasurementsState, result: MeasurementsResult): MeasurementsState = when (result) { + MeasurementsResult.Loading -> state.copy(isLoading = true, errorCode = null) + + is MeasurementsResult.Loaded -> state.copy( + measurements = result.measurements, + isLoading = false, + errorCode = null, + ) + + is MeasurementsResult.LoadFailed -> state.copy(isLoading = false, errorCode = result.errorCode) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsRoot.kt new file mode 100644 index 0000000..ae3fb31 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsRoot.kt @@ -0,0 +1,27 @@ +package com.tomasrepcik.sensorbox.measurements.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun MeasurementsRoot( + onOpenDetails: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: MeasurementsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is MeasurementsEffect.OpenDetails -> onOpenDetails(effect.measurementId) + } + } + } + LaunchedEffect(viewModel) { viewModel.accept(MeasurementsIntent.Refresh) } + MeasurementsScreen(state, viewModel::accept, onBack, modifier) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsScreen.kt similarity index 63% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsScreen.kt index 5298f88..74a3db4 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsScreen.kt @@ -1,19 +1,19 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.list import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.measurements.components.measurementArchiveItems @Composable fun MeasurementsScreen( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, + state: MeasurementsState, + onIntent: (MeasurementsIntent) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { - LaunchedEffect(Unit) { onIntent(MeasurementBrowserIntent.RefreshMeasurements) } SensorBoxBackScreen( title = stringResource(R.string.measurements), onBack = onBack, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModel.kt new file mode 100644 index 0000000..830067b --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModel.kt @@ -0,0 +1,56 @@ +package com.tomasrepcik.sensorbox.measurements.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.measurements.storage.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 MeasurementsViewModel @Inject constructor( + private val repository: MeasurementRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(MeasurementsState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + fun accept(intent: MeasurementsIntent) { + when (intent) { + MeasurementsIntent.Refresh -> refresh() + + is MeasurementsIntent.OpenDetails -> mutableEffects.trySend( + MeasurementsEffect.OpenDetails(intent.measurementId), + ) + } + } + + private fun refresh() { + viewModelScope.launch { + mutableState.value = MeasurementsReducer.reduce(state.value, MeasurementsResult.Loading) + repository.loadMeasurements().fold( + onSuccess = { measurements -> + mutableState.value = MeasurementsReducer.reduce( + state.value, + MeasurementsResult.Loaded(measurements), + ) + }, + onFailure = { error -> + mutableState.value = MeasurementsReducer.reduce( + state.value, + MeasurementsResult.LoadFailed(error.code), + ) + appFailures.show(error) + }, + ) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewComponents.kt similarity index 86% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewComponents.kt index 9ea0917..b7d9cb1 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.preview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,13 +7,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp 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.MeasurementFileContent +import com.tomasrepcik.sensorbox.design.SensorBoxPanel +import com.tomasrepcik.sensorbox.measurements.storage.GpsCoordinate +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent import java.text.DateFormat import java.util.Date @@ -21,7 +23,7 @@ import java.util.Date internal fun SensorSeriesSummary(content: MeasurementFileContent.SensorSeries) { Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { Text( - stringResource(R.string.chart_sample_count, content.samples.size), + pluralStringResource(R.plurals.chart_sample_count, content.samples.size, content.samples.size), style = MaterialTheme.typography.titleMedium, ) Text(content.columns.joinToString(), color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -35,7 +37,7 @@ internal fun SensorSeriesSummary(content: MeasurementFileContent.SensorSeries) { internal fun CoordinateSummary(content: MeasurementFileContent.GpsCoordinates) { Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { Text( - stringResource(R.string.coordinate_count, content.coordinates.size), + pluralStringResource(R.plurals.coordinate_count, content.coordinates.size, content.coordinates.size), style = MaterialTheme.typography.titleMedium, ) if (content.truncated) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewContract.kt new file mode 100644 index 0000000..0516585 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewContract.kt @@ -0,0 +1,107 @@ +package com.tomasrepcik.sensorbox.measurements.preview + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileSummary +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry + +data class MeasurementPreviewState( + val file: MeasurementFileSummary? = null, + val content: MeasurementFileContent? = null, + val sensorMetadata: List = emptyList(), + val sensorChartTimeWindow: SensorChartTimeWindow = SensorChartTimeWindow(), + val progress: Float = 0f, + 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 MeasurementPreviewIntent { + data class Load(val measurementId: String, val fileId: String) : MeasurementPreviewIntent + data class ZoomSensorChartTimeWindow(val zoomFactor: Float, val focalPointFraction: Float) : + MeasurementPreviewIntent + + data class ShiftSensorChartTimeWindow(val visibleWindowFraction: Float) : MeasurementPreviewIntent + data object ShowEntireSensorChartTimeRange : MeasurementPreviewIntent +} + +internal sealed interface MeasurementPreviewResult { + data object Loading : MeasurementPreviewResult + + data class FileFound(val file: MeasurementFileSummary, val sensorMetadata: List) : + MeasurementPreviewResult + + data class Progress(val progress: Float) : MeasurementPreviewResult + data class Loaded(val content: MeasurementFileContent) : MeasurementPreviewResult + data class LoadFailed(val errorCode: AppErrorCode) : MeasurementPreviewResult +} + +internal object MeasurementPreviewReducer { + fun reduce(state: MeasurementPreviewState, result: MeasurementPreviewResult): MeasurementPreviewState = + when (result) { + MeasurementPreviewResult.Loading -> MeasurementPreviewState(isLoading = true) + + is MeasurementPreviewResult.FileFound -> state.copy( + file = result.file, + sensorMetadata = result.sensorMetadata, + ) + + is MeasurementPreviewResult.Progress -> state.copy( + progress = result.progress.coerceIn(0f, 1f), + ) + + is MeasurementPreviewResult.Loaded -> state.copy( + content = result.content, + sensorChartTimeWindow = SensorChartTimeWindow(), + progress = 1f, + isLoading = false, + errorCode = null, + ) + + is MeasurementPreviewResult.LoadFailed -> state.copy( + isLoading = false, + errorCode = result.errorCode, + ) + } + + fun zoomSensorChartTimeWindow( + state: MeasurementPreviewState, + zoomFactor: Float, + focalPointFraction: Float, + ): MeasurementPreviewState { + 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: MeasurementPreviewState, + visibleWindowFraction: Float, + ): MeasurementPreviewState { + 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: MeasurementPreviewState): MeasurementPreviewState = + state.copy(sensorChartTimeWindow = SensorChartTimeWindow()) + + private const val MINIMUM_VISIBLE_TIME_FRACTION = 0.01f +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewRoot.kt new file mode 100644 index 0000000..9a75506 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewRoot.kt @@ -0,0 +1,23 @@ +package com.tomasrepcik.sensorbox.measurements.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun MeasurementPreviewRoot( + measurementId: String, + fileId: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: MeasurementPreviewViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel, measurementId, fileId) { + viewModel.accept(MeasurementPreviewIntent.Load(measurementId, fileId)) + } + MeasurementPreviewScreen(state, viewModel::accept, onBack, modifier) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewScreen.kt new file mode 100644 index 0000000..96b2709 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewScreen.kt @@ -0,0 +1,105 @@ +package com.tomasrepcik.sensorbox.measurements.preview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsSection +import com.tomasrepcik.sensorbox.measurements.components.ArchiveError +import com.tomasrepcik.sensorbox.measurements.components.MetadataRow +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry +import kotlin.math.roundToInt + +@Composable +fun MeasurementPreviewScreen( + state: MeasurementPreviewState, + onIntent: (MeasurementPreviewIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SensorBoxBackScreen( + title = state.file?.name ?: stringResource(R.string.measurement_file), + onBack = onBack, + modifier = modifier, + ) { + when { + state.isLoading -> item { + MeasurementPreviewProgress( + progress = state.progress, + modifier = Modifier.fillParentMaxHeight(), + ) + } + + state.errorCode != null -> item { ArchiveError() } + + else -> measurementPreviewItems( + state.content, + state.sensorMetadata, + state.sensorChartTimeWindow, + onIntent, + ) + } + } +} + +@Composable +private fun MeasurementPreviewProgress(progress: Float, modifier: Modifier = Modifier) { + val boundedProgress = progress.coerceIn(0f, 1f) + Column( + modifier = modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + CircularProgressIndicator(progress = { boundedProgress }) + Text( + stringResource( + R.string.loading_measurement_file_progress, + (boundedProgress * 100).roundToInt(), + ), + ) + } +} + +private fun LazyListScope.measurementPreviewItems( + content: MeasurementFileContent?, + sensorMetadata: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementPreviewIntent) -> Unit, +) { + when (content) { + is MeasurementFileContent.SensorSeries -> sensorSeriesItems(content, sensorMetadata, timeWindow, onIntent) + is MeasurementFileContent.GpsCoordinates -> gpsCoordinateItems(content) + is MeasurementFileContent.Text -> item { StoredText(content) } + null -> item { ArchiveError() } + } +} + +private fun LazyListScope.sensorSeriesItems( + content: MeasurementFileContent.SensorSeries, + metadata: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementPreviewIntent) -> Unit, +) { + if (metadata.isNotEmpty()) { + item { SensorBoxSettingsSection(stringResource(R.string.sensor_details)) } + metadata.forEach { entry -> item { MetadataRow(entry) } } + } + item { SensorSeriesSummary(content) } + item { StoredSensorChart(content.columns, content.samples, timeWindow, onIntent) } +} + +private fun LazyListScope.gpsCoordinateItems(content: MeasurementFileContent.GpsCoordinates) { + item { CoordinateSummary(content) } + content.coordinates.forEachIndexed { index, coordinate -> item { CoordinateRow(index + 1, coordinate) } } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModel.kt new file mode 100644 index 0000000..19caf41 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModel.kt @@ -0,0 +1,130 @@ +package com.tomasrepcik.sensorbox.measurements.preview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementDetails +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MeasurementPreviewViewModel @Inject constructor( + private val repository: MeasurementRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(MeasurementPreviewState()) + private var activeMeasurementId: String? = null + private var activeFileId: String? = null + private var loadSequence = 0L + private var loadJob: Job? = null + + val state = mutableState.asStateFlow() + + fun accept(intent: MeasurementPreviewIntent) { + when (intent) { + is MeasurementPreviewIntent.Load -> load(intent.measurementId, intent.fileId) + + is MeasurementPreviewIntent.ZoomSensorChartTimeWindow -> { + mutableState.value = MeasurementPreviewReducer.zoomSensorChartTimeWindow( + state.value, + intent.zoomFactor, + intent.focalPointFraction, + ) + } + + is MeasurementPreviewIntent.ShiftSensorChartTimeWindow -> { + mutableState.value = MeasurementPreviewReducer.shiftSensorChartTimeWindow( + state.value, + intent.visibleWindowFraction, + ) + } + + MeasurementPreviewIntent.ShowEntireSensorChartTimeRange -> { + mutableState.value = MeasurementPreviewReducer.showEntireSensorChartTimeRange(state.value) + } + } + } + + private fun load(measurementId: String, fileId: String) { + val sameFile = activeMeasurementId == measurementId && activeFileId == fileId + if (sameFile && (state.value.isLoading || state.value.content != null)) return + + activeMeasurementId = measurementId + activeFileId = fileId + loadSequence += 1 + val sequence = loadSequence + loadJob?.cancel() + mutableState.value = MeasurementPreviewReducer.reduce( + state.value, + MeasurementPreviewResult.Loading, + ) + loadJob = viewModelScope.launch { loadFromArchive(measurementId, fileId, sequence) } + } + + private suspend fun loadFromArchive(measurementId: String, fileId: String, sequence: Long) { + when (val detailsResult = repository.loadMeasurementDetails(measurementId)) { + is AppResult.Success -> loadFile(detailsResult.value, measurementId, fileId, sequence) + is AppResult.Failure -> failLoad(detailsResult.error, sequence) + } + } + + private suspend fun loadFile(details: MeasurementDetails, measurementId: String, fileId: String, sequence: Long) { + val file = details.files.firstOrNull { it.id == fileId } + if (file == null) { + failLoad(AppError(AppErrorCode.STORAGE, "Find measurement file"), sequence) + return + } + if (sequence != loadSequence) return + mutableState.value = MeasurementPreviewReducer.reduce( + state.value, + MeasurementPreviewResult.FileFound( + file = file, + sensorMetadata = details.sensorMetadataByFile[file.id].orEmpty(), + ), + ) + val contentResult = repository.loadMeasurementFile(measurementId, fileId) { progress -> + reportProgress(sequence, progress) + } + when (contentResult) { + is AppResult.Success -> completeLoad(contentResult.value, sequence) + is AppResult.Failure -> failLoad(contentResult.error, sequence) + } + } + + private fun reportProgress(sequence: Long, progress: Float) { + mutableState.update { current -> + if (sequence == loadSequence) { + MeasurementPreviewReducer.reduce(current, MeasurementPreviewResult.Progress(progress)) + } else { + current + } + } + } + + private fun completeLoad(content: MeasurementFileContent, sequence: Long) { + if (sequence != loadSequence) return + mutableState.value = MeasurementPreviewReducer.reduce( + state.value, + MeasurementPreviewResult.Loaded(content), + ) + } + + private fun failLoad(error: AppError, sequence: Long) { + if (sequence != loadSequence) return + mutableState.value = MeasurementPreviewReducer.reduce( + state.value, + MeasurementPreviewResult.LoadFailed(error.code), + ) + appFailures.show(error) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/SensorChartTimeSupport.kt similarity index 91% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/SensorChartTimeSupport.kt index 7d250b9..dd7db48 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorChartTimeSupport.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/SensorChartTimeSupport.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.preview -import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import com.tomasrepcik.sensorbox.measurements.storage.SensorSeriesSample internal fun selectSensorSamplesInTimeWindow( samples: List, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/StoredSensorChart.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/StoredSensorChart.kt index 4bec641..fe2557e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/StoredSensorChart.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.measurements.preview import android.graphics.Paint import androidx.compose.foundation.Canvas @@ -32,7 +32,7 @@ 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.SensorSeriesSample +import com.tomasrepcik.sensorbox.measurements.storage.SensorSeriesSample import kotlin.math.max @Composable @@ -40,7 +40,7 @@ internal fun StoredSensorChart( columns: List, samples: List, timeWindow: SensorChartTimeWindow, - onIntent: (MeasurementBrowserIntent) -> Unit, + onIntent: (MeasurementPreviewIntent) -> Unit, ) { if (samples.isEmpty() || columns.isEmpty()) { Text(stringResource(R.string.no_samples), color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -89,7 +89,7 @@ private fun StoredChartPlot( viewport: StoredChartViewport, columnCount: Int, colors: List, - onIntent: (MeasurementBrowserIntent) -> Unit, + onIntent: (MeasurementPreviewIntent) -> Unit, ) { Canvas( Modifier @@ -101,7 +101,7 @@ private fun StoredChartPlot( val chartWidth = size.width.coerceAtLeast(1).toFloat() if (zoom != 1f) { onIntent( - MeasurementBrowserIntent.ZoomSensorChartTimeWindow( + MeasurementPreviewIntent.ZoomSensorChartTimeWindow( zoomFactor = zoom, focalPointFraction = centroid.x / chartWidth, ), @@ -109,7 +109,7 @@ private fun StoredChartPlot( } if (pan.x != 0f) { onIntent( - MeasurementBrowserIntent.ShiftSensorChartTimeWindow( + MeasurementPreviewIntent.ShiftSensorChartTimeWindow( visibleWindowFraction = -pan.x / chartWidth, ), ) @@ -122,15 +122,15 @@ private fun StoredChartPlot( } @Composable -private fun StoredChartControls(onIntent: (MeasurementBrowserIntent) -> Unit) { +private fun StoredChartControls(onIntent: (MeasurementPreviewIntent) -> Unit) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - TextButton(onClick = { onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(0.5f, 0.5f)) }) { + TextButton(onClick = { onIntent(MeasurementPreviewIntent.ZoomSensorChartTimeWindow(0.5f, 0.5f)) }) { Text(stringResource(R.string.sensor_chart_zoom_out)) } - TextButton(onClick = { onIntent(MeasurementBrowserIntent.ShowEntireSensorChartTimeRange) }) { + TextButton(onClick = { onIntent(MeasurementPreviewIntent.ShowEntireSensorChartTimeRange) }) { Text(stringResource(R.string.sensor_chart_show_all)) } - TextButton(onClick = { onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) }) { + TextButton(onClick = { onIntent(MeasurementPreviewIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) }) { Text(stringResource(R.string.sensor_chart_zoom_in)) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/AndroidMeasurementRepository.kt similarity index 56% rename from app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/AndroidMeasurementRepository.kt index 6f96b7d..c71b508 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/AndroidMeasurementRepository.kt @@ -1,18 +1,10 @@ -package com.tomasrepcik.sensorbox.data.measurements +package com.tomasrepcik.sensorbox.measurements.storage 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.MeasurementRepository -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary -import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -23,6 +15,8 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.longOrNull +import java.io.FilterInputStream +import java.io.InputStream import javax.inject.Inject class AndroidMeasurementRepository @Inject constructor(@ApplicationContext private val context: Context) : @@ -59,23 +53,26 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva } } - override suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult = - withContext(Dispatchers.IO) { - suspendAppResult(AppErrorCode.STORAGE, "Read measurement file") { - val directory = measurementDirectory(measurementId) - val document = directory.findFile(fileId) - ?.takeIf(DocumentFile::isFile) - ?: error("Measurement file is unavailable") - when { - document.name.equals(GPS_FILE, ignoreCase = true) -> parseGpsCoordinates(document) + override suspend fun loadMeasurementFile( + measurementId: String, + fileId: String, + onProgress: (Float) -> Unit, + ): AppResult = withContext(Dispatchers.IO) { + suspendAppResult(AppErrorCode.STORAGE, "Read measurement file") { + val directory = measurementDirectory(measurementId) + val document = directory.findFile(fileId) + ?.takeIf(DocumentFile::isFile) + ?: error("Measurement file is unavailable") + when { + document.name.equals(GPS_FILE, ignoreCase = true) -> parseGpsCoordinates(document, onProgress) - document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> - parseSensorSeries(document, readSensorTimeAnchor(directory)) + document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> + parseSensorSeries(document, readSensorTimeAnchor(directory), onProgress) - else -> parseTextFile(document) - } + else -> parseTextFile(document, onProgress) } } + } private fun selectedMeasurementsDirectory(): DocumentFile { val permission = context.contentResolver.persistedUriPermissions @@ -115,54 +112,103 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva private fun parseSensorSeries( document: DocumentFile, anchor: SensorTimeAnchor?, + onProgress: (Float) -> Unit, ): MeasurementFileContent.SensorSeries { - var columns = emptyList() val samples = mutableListOf() var totalSamples = 0 - openDocumentStream(document).bufferedReader().useLines { lines -> + + val columns = readSensorSamples( + document = document, + anchor = anchor, + onProgress = { progress -> onProgress(progress / 2f) }, + onSample = { sample -> + totalSamples += 1 + if (samples.size < MAX_CHART_SAMPLES) samples += sample + }, + ) + + if (totalSamples <= MAX_CHART_SAMPLES) { + onProgress(1f) + return MeasurementFileContent.SensorSeries(columns, samples, truncated = false) + } + + val selectedIndexes = chartSampleIndexes(totalSamples, MAX_CHART_SAMPLES) + samples.clear() + var sampleIndex = 0 + var selectedIndex = 0 + + readSensorSamples( + document = document, + anchor = anchor, + onProgress = { progress -> onProgress(0.5f + progress / 2f) }, + onSample = { sample -> + if (selectedIndex < selectedIndexes.size && sampleIndex == selectedIndexes[selectedIndex]) { + samples += sample + selectedIndex += 1 + } + sampleIndex += 1 + }, + ) + onProgress(1f) + + return MeasurementFileContent.SensorSeries(columns, samples, truncated = true) + } + + private fun readSensorSamples( + document: DocumentFile, + anchor: SensorTimeAnchor?, + onProgress: (Float) -> Unit, + onSample: (SensorSeriesSample) -> Unit, + ): List { + var columns = emptyList() + openDocumentStream(document, onProgress).bufferedReader().useLines { lines -> val iterator = lines.iterator() if (!iterator.hasNext()) return@useLines - val header = iterator.next().split(DELIMITER) - val unixTimestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } - val sensorTimestampIndex = header.indexOf("t_sensor").takeIf { it >= 0 } - val timestampIndex = unixTimestampIndex ?: sensorTimestampIndex ?: 0 - val valueIndexes = header.indices.filter { index -> - index != timestampIndex && header[index] !in NON_VALUE_COLUMNS - } - columns = valueIndexes.map(header::get) + + val format = sensorSeriesFormat(iterator.next().split(DELIMITER)) + columns = format.columns + while (iterator.hasNext()) { parseSensorSample( line = iterator.next(), - timestampIndex = timestampIndex, - valueIndexes = valueIndexes, - sensorTimestamp = sensorTimestampIndex != null, + format = format, anchor = anchor, - )?.let { sample -> - totalSamples += 1 - retainBounded(samples, sample, totalSamples, MAX_CHART_SAMPLES) - } + )?.let(onSample) } } - return MeasurementFileContent.SensorSeries(columns, samples, totalSamples > samples.size) + return columns + } + + private fun sensorSeriesFormat(header: List): SensorSeriesFormat { + val unixTimestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } + val sensorTimestampIndex = header.indexOf("t_sensor").takeIf { it >= 0 } + val timestampIndex = unixTimestampIndex ?: sensorTimestampIndex ?: 0 + val valueIndexes = header.indices.filter { index -> + index != timestampIndex && header[index] !in NON_VALUE_COLUMNS + } + return SensorSeriesFormat( + columns = valueIndexes.map(header::get), + timestampIndex = timestampIndex, + valueIndexes = valueIndexes, + usesSensorTimestamp = sensorTimestampIndex != null, + ) } private fun parseSensorSample( line: String, - timestampIndex: Int, - valueIndexes: List, - sensorTimestamp: Boolean, + format: SensorSeriesFormat, anchor: SensorTimeAnchor?, ): SensorSeriesSample? { val fields = line.split(DELIMITER) - val rawTimestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null - val timestamp = if (sensorTimestamp) { + val rawTimestamp = fields.getOrNull(format.timestampIndex)?.toLongOrNull() ?: return null + val timestamp = if (format.usesSensorTimestamp) { anchor?.unixMillis?.plus((rawTimestamp - anchor.elapsedRealtimeNanos) / NANOS_PER_MILLISECOND) - ?: rawTimestamp / NANOS_PER_MILLISECOND + ?: (rawTimestamp / NANOS_PER_MILLISECOND) } else { rawTimestamp } - val values = valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() } - return values.takeIf { it.size == valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) } + val values = format.valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() } + return values.takeIf { it.size == format.valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) } } private fun readSensorTimeAnchor(directory: DocumentFile): SensorTimeAnchor? { @@ -182,10 +228,13 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva ?.takeIf(String::isNotBlank) ?.let(::parseMetadataObject) - private fun parseGpsCoordinates(document: DocumentFile): MeasurementFileContent.GpsCoordinates { + private fun parseGpsCoordinates( + document: DocumentFile, + onProgress: (Float) -> Unit, + ): MeasurementFileContent.GpsCoordinates { val coordinates = mutableListOf() var totalCoordinates = 0 - openDocumentStream(document).bufferedReader().useLines { lines -> + openDocumentStream(document, onProgress).bufferedReader().useLines { lines -> lines.drop(1).forEach { line -> val fields = line.split(DELIMITER) val coordinate = GpsCoordinate( @@ -202,25 +251,27 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva if (coordinates.size < MAX_GPS_COORDINATES) coordinates += coordinate } } + onProgress(1f) return MeasurementFileContent.GpsCoordinates( coordinates, truncated = totalCoordinates > coordinates.size, ) } - private fun parseTextFile(document: DocumentFile): MeasurementFileContent.Text { - val text = openDocumentStream(document).bufferedReader().use { reader -> + private fun parseTextFile(document: DocumentFile, onProgress: (Float) -> Unit): MeasurementFileContent.Text { + val text = openDocumentStream(document, onProgress).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) + appendRange(buffer, 0, count) remaining -= count } } } + onProgress(1f) return MeasurementFileContent.Text(text, truncated = document.length() > text.length) } @@ -228,8 +279,16 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva .bufferedReader() .use { it.readText() } - private fun openDocumentStream(document: DocumentFile) = context.contentResolver.openInputStream(document.uri) - ?: throw IllegalStateException("Measurement file cannot be opened") + private fun openDocumentStream(document: DocumentFile, onProgress: ((Float) -> Unit)? = null): InputStream { + val stream = context.contentResolver.openInputStream(document.uri) + ?: throw IllegalStateException("Measurement file cannot be opened") + val sizeBytes = document.length() + return if (onProgress != null && sizeBytes > 0L) { + ProgressInputStream(stream, sizeBytes, onProgress) + } else { + stream + } + } private fun parseMetadataObject(value: String): JsonObject = JSON.parseToJsonElement(value).jsonObject @@ -239,15 +298,6 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva .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" @@ -257,11 +307,63 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva 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 const val NANOS_PER_MILLISECOND = 1_000_000L val NON_VALUE_COLUMNS = setOf("t_sensor", "accuracy", "provider") val JSON = Json { ignoreUnknownKeys = true } } private data class SensorTimeAnchor(val unixMillis: Long, val elapsedRealtimeNanos: Long) + + private data class SensorSeriesFormat( + val columns: List, + val timestampIndex: Int, + val valueIndexes: List, + val usesSensorTimestamp: Boolean, + ) +} + +private class ProgressInputStream( + input: InputStream, + private val sizeBytes: Long, + private val onProgress: (Float) -> Unit, +) : FilterInputStream(input) { + private var readBytes = 0L + private var reportedPercent = -1 + + override fun read(): Int { + val value = super.read() + if (value >= 0) reportBytesRead(1) + return value + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val count = super.read(buffer, offset, length) + if (count > 0) reportBytesRead(count) + return count + } + + private fun reportBytesRead(count: Int) { + readBytes += count + val progress = (readBytes.toFloat() / sizeBytes).coerceIn(0f, 1f) + val percent = (progress * 100).toInt() + if (percent > reportedPercent) { + reportedPercent = percent + onProgress(progress) + } + } +} + +internal fun chartSampleIndexes(totalSamples: Int, limit: Int): IntArray { + require(totalSamples >= 0) + require(limit > 0) + + val selectedSamples = minOf(totalSamples, limit) + if (selectedSamples == 0) return IntArray(0) + if (selectedSamples == 1) return intArrayOf(0) + + val lastSampleIndex = totalSamples - 1L + val lastSelectedIndex = selectedSamples - 1L + return IntArray(selectedSamples) { selectedIndex -> + (selectedIndex * lastSampleIndex / lastSelectedIndex).toInt() + } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParser.kt similarity index 95% rename from app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParser.kt index e698c71..6d92120 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParser.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.data.measurements +package com.tomasrepcik.sensorbox.measurements.storage -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementRepository.kt similarity index 85% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementRepository.kt index bb490fe..e99c871 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementRepository.kt @@ -1,13 +1,17 @@ -package com.tomasrepcik.sensorbox.domain.measurements +package com.tomasrepcik.sensorbox.measurements.storage -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult interface MeasurementRepository { suspend fun loadMeasurements(): AppResult> suspend fun loadMeasurementDetails(measurementId: String): AppResult - suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult + suspend fun loadMeasurementFile( + measurementId: String, + fileId: String, + onProgress: (Float) -> Unit, + ): AppResult } data class MeasurementSummary( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/ReceiveWatchFileUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt similarity index 64% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/ReceiveWatchFileUseCase.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt index e0d5172..9a5feee 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/ReceiveWatchFileUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt @@ -1,13 +1,13 @@ -package com.tomasrepcik.sensorbox.domain.sync +package com.tomasrepcik.sensorbox.measurements.sync import com.google.android.gms.wearable.ChannelClient -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.flatMap -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.wearoslib.files.WearFilePathCodec -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileTransferClient +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFilePathCodec +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient import javax.inject.Inject class ReceiveWatchFileUseCase @Inject constructor( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt index cd02ee0..f359906 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt @@ -1,14 +1,14 @@ -package com.tomasrepcik.sensorbox.domain.sync +package com.tomasrepcik.sensorbox.measurements.sync import android.content.Context import android.content.pm.ApplicationInfo -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 -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import com.tomasrepcik.sensorbox.core.storage.DocumentStorage -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileMetadata +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.InputStream diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainContract.kt new file mode 100644 index 0000000..11be825 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainContract.kt @@ -0,0 +1,46 @@ +package com.tomasrepcik.sensorbox.navigation + +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingDraft +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import kotlinx.serialization.Serializable + +@Serializable +enum class MainRoute : NavKey { + ONBOARDING, + RECORD, + ACTIVE_RECORDING, + MEASUREMENTS, + SETTINGS, + DIAGNOSTICS, + LICENSES, + PRIVACY, +} + +@Serializable +data class MeasurementDetailsRoute(val measurementId: String) : NavKey + +@Serializable +data class MeasurementPreviewRoute(val measurementId: String, val fileId: String) : NavKey + +@Serializable +data class SensorDetailsRoute(val sensorType: Int?, val device: RecordingDevice) : NavKey + +@Serializable +data class SensorPreviewRoute(val sensorType: Int?, val device: RecordingDevice) : NavKey + +@Serializable +data class RecordingSetupRoute(val draft: RecordingDraft) : NavKey + +data class MainState( + val route: NavKey = MainRoute.ONBOARDING, + val session: RecordingSessionState = RecordingSessionState.Idle, + val keepScreenAwake: Boolean = false, + val themeMode: AppThemeMode = AppThemeMode.AUTOMATIC, + val dynamicColors: Boolean = true, + val hasLoadedPreferences: Boolean = false, + val visibleFailureCode: AppErrorCode? = null, +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainViewModel.kt similarity index 89% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainViewModel.kt index 852eb58..4293c0e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/MainViewModel.kt @@ -1,8 +1,9 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.navigation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppFailureStore +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import dagger.hilt.android.lifecycle.HiltViewModel @@ -29,7 +30,7 @@ class MainViewModel @Inject constructor( observeFailures() } - fun navigate(route: MainRoute) { + fun navigate(route: NavKey) { hasChosenInitialRoute = true mutableState.value = state.value.copy(route = route) } @@ -61,6 +62,8 @@ class MainViewModel @Inject constructor( mutableState.value = state.value.copy( route = route, keepScreenAwake = preferences.display.keepPhoneDisplayOn, + themeMode = preferences.display.themeMode, + dynamicColors = preferences.display.dynamicColors, hasLoadedPreferences = true, ) }, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxApp.kt similarity index 69% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxApp.kt index f830687..2fd6e98 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxApp.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.navigation import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.FastOutSlowInEasing @@ -21,32 +21,45 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.diagnostics.AppErrorScreen import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState @Composable -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, - onDismissFailure: () -> Unit, -) { +fun SensorBoxRoot(showPrivacyRationale: Boolean, viewModel: MainViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel, showPrivacyRationale) { + if (showPrivacyRationale) viewModel.showPrivacyRationale() + } + SensorBoxTheme( + themeMode = state.themeMode, + dynamicColor = state.dynamicColors, + ) { + SensorBoxApp( + mainState = state, + onNavigate = viewModel::navigate, + onDismissFailure = viewModel::dismissFailure, + ) + } +} + +@Composable +fun SensorBoxApp(mainState: MainState, onNavigate: (NavKey) -> Unit, onDismissFailure: () -> Unit) { if (!mainState.hasLoadedPreferences) { Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) return @@ -58,31 +71,12 @@ fun SensorBoxApp( if (mainState.keepScreenAwake && mainState.session is RecordingSessionState.Running) KeepScreenAwake() SensorBoxNavHost( mainState = mainState, - onboardingState = onboardingState, - recordingState = recordingState, - measurementBrowserState = measurementBrowserState, - settingsState = settingsState, onNavigate = onNavigate, - onOnboardingIntent = onOnboardingIntent, - onRecordingIntent = onRecordingIntent, - onMeasurementBrowserIntent = onMeasurementBrowserIntent, - onSettingsIntent = onSettingsIntent, ) } @Composable -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, -) { +private fun SensorBoxNavHost(mainState: MainState, onNavigate: (NavKey) -> Unit) { val backStack = rememberNavBackStack(mainState.route) val displayedRoute = mainState.displayedRoute() SynchronizeBackStack(backStack, displayedRoute) @@ -94,21 +88,33 @@ private fun SensorBoxNavHost( transitionSpec = { forwardScreenTransition() }, popTransitionSpec = { backwardScreenTransition() }, predictivePopTransitionSpec = { backwardScreenTransition() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), entryProvider = entryProvider { entry { route -> RouteContent( route = route, - onboardingState = onboardingState, - recordingState = recordingState, - measurementBrowserState = measurementBrowserState, - settingsState = settingsState, - onOnboardingIntent = onOnboardingIntent, - onRecordingIntent = onRecordingIntent, - onMeasurementBrowserIntent = onMeasurementBrowserIntent, - onSettingsIntent = onSettingsIntent, + onNavigate = onNavigate, onBack = navigateBack, ) } + entry { route -> + RecordingDestinationContent(route, onNavigate, navigateBack) + } + entry { route -> + RecordingDestinationContent(route, onNavigate, navigateBack) + } + entry { route -> + RecordingDestinationContent(route, onNavigate, navigateBack) + } + entry { route -> + MeasurementDestinationContent(route, onNavigate, navigateBack) + } + entry { route -> + MeasurementDestinationContent(route, onNavigate, navigateBack) + } }, ) } @@ -120,7 +126,7 @@ private fun MainState.displayedRoute() = if (session is RecordingSessionState.Ru } @Composable -private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute: MainRoute) { +private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute: NavKey) { LaunchedEffect(displayedRoute) { if (displayedRoute != MainRoute.ACTIVE_RECORDING && backStack.lastOrNull() == MainRoute.ACTIVE_RECORDING) { backStack.removeLastOrNull() @@ -132,31 +138,14 @@ private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute } } -private fun navigateBack(state: MainState, backStack: NavBackStack, onNavigate: (MainRoute) -> Unit) { +private fun navigateBack(state: MainState, backStack: NavBackStack, onNavigate: (NavKey) -> Unit) { if (state.session is RecordingSessionState.Running) return if (backStack.size > 1) backStack.removeLastOrNull() - val destination = backStack.lastOrNull() as? MainRoute ?: MainRoute.RECORD + val destination = backStack.lastOrNull() ?: MainRoute.RECORD if (destination != MainRoute.ACTIVE_RECORDING) onNavigate(destination) } -private fun MainRoute.isRootDestination() = when (this) { - MainRoute.ONBOARDING, - MainRoute.RECORD, - -> true - - MainRoute.ACTIVE_RECORDING, - MainRoute.SENSOR_DETAILS, - MainRoute.SENSOR_PREVIEW, - MainRoute.RECORDING_SETUP, - MainRoute.MEASUREMENTS, - MainRoute.MEASUREMENT_DETAILS, - MainRoute.MEASUREMENT_FILE, - MainRoute.SETTINGS, - MainRoute.DIAGNOSTICS, - MainRoute.LICENSES, - MainRoute.PRIVACY, - -> false -} +private fun NavKey.isRootDestination() = this == MainRoute.ONBOARDING || this == MainRoute.RECORD private fun forwardScreenTransition(): ContentTransform = (slideInHorizontally(screenTween()) { width -> width / SCREEN_SLIDE_DIVISOR } + fadeIn(screenFadeInTween())) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxRoutes.kt b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxRoutes.kt new file mode 100644 index 0000000..31c1b8e --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/navigation/SensorBoxRoutes.kt @@ -0,0 +1,94 @@ +package com.tomasrepcik.sensorbox.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.measurements.details.MeasurementDetailsRoot +import com.tomasrepcik.sensorbox.measurements.list.MeasurementsRoot +import com.tomasrepcik.sensorbox.measurements.preview.MeasurementPreviewRoot +import com.tomasrepcik.sensorbox.onboarding.OnboardingRoot +import com.tomasrepcik.sensorbox.recording.active.ActiveRecordingRoot +import com.tomasrepcik.sensorbox.recording.active.RecordRoot +import com.tomasrepcik.sensorbox.recording.details.SensorDetailsRoot +import com.tomasrepcik.sensorbox.recording.preview.SensorPreviewRoot +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetupRoot +import com.tomasrepcik.sensorbox.settings.SettingsRoot + +@Composable +internal fun RouteContent(route: MainRoute, onNavigate: (NavKey) -> Unit, onBack: () -> Unit) { + when (route) { + MainRoute.ONBOARDING -> OnboardingRoot(onNavigate) + + MainRoute.ACTIVE_RECORDING -> ActiveRecordingRoot(onNavigate) + + MainRoute.RECORD -> RecordRoot(onNavigate) + + MainRoute.MEASUREMENTS -> MeasurementRouteContent(route, onNavigate, onBack) + + MainRoute.SETTINGS, + MainRoute.DIAGNOSTICS, + MainRoute.LICENSES, + MainRoute.PRIVACY, + -> SettingsRoot(route, onNavigate, onBack) + } +} + +@Composable +internal fun RecordingDestinationContent(route: NavKey, onNavigate: (NavKey) -> Unit, onBack: () -> Unit) { + when (route) { + is SensorDetailsRoute -> SensorDetailsRoot( + sensorType = route.sensorType, + device = route.device, + onNavigate = onNavigate, + onBack = onBack, + ) + + is SensorPreviewRoute -> SensorPreviewRoot( + sensorType = route.sensorType, + device = route.device, + onBack = onBack, + ) + + is RecordingSetupRoute -> RecordingSetupRoot( + draft = route.draft, + onBack = onBack, + ) + + else -> error("Not a recording route") + } +} + +@Composable +private fun MeasurementRouteContent(route: NavKey, onNavigate: (NavKey) -> Unit, onBack: () -> Unit) { + FullScreen { modifier -> + when (route) { + MainRoute.MEASUREMENTS -> MeasurementsRoot( + onOpenDetails = { measurementId -> onNavigate(MeasurementDetailsRoute(measurementId)) }, + onBack = onBack, + modifier = modifier, + ) + + is MeasurementDetailsRoute -> MeasurementDetailsRoot( + measurementId = route.measurementId, + onOpenFile = { measurementId, fileId -> + onNavigate(MeasurementPreviewRoute(measurementId, fileId)) + }, + onBack = onBack, + modifier = modifier, + ) + + is MeasurementPreviewRoute -> MeasurementPreviewRoot( + measurementId = route.measurementId, + fileId = route.fileId, + onBack = onBack, + modifier = modifier, + ) + + else -> error("Not a measurement route") + } + } +} + +@Composable +internal fun MeasurementDestinationContent(route: NavKey, onNavigate: (NavKey) -> Unit, onBack: () -> Unit) { + MeasurementRouteContent(route, onNavigate, onBack) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingComponents.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingComponents.kt index 4eae76f..486ac4f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -32,6 +32,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackButton +import com.tomasrepcik.sensorbox.design.SensorBoxSecondaryButton @Composable internal fun OnboardingHeader(pageIndex: Int, onIntent: (OnboardingIntent) -> Unit, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingContract.kt similarity index 85% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingContract.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingContract.kt index ca26fd7..263289f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingContract.kt @@ -1,6 +1,7 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.navigation.MainRoute data class OnboardingState( val page: Int = 0, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingLayout.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingLayout.kt index 6f66514..f8d1136 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingLayout.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingPages.kt similarity index 96% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingPages.kt index e83d2de..66c150f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingPages.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingRoot.kt new file mode 100644 index 0000000..bb0d71a --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingRoot.kt @@ -0,0 +1,48 @@ +package com.tomasrepcik.sensorbox.onboarding + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.platform.openWebPage +import com.tomasrepcik.sensorbox.platform.rememberRecordingArchivePicker +import com.tomasrepcik.sensorbox.platform.requestBatteryOptimizationExemption + +@Composable +fun OnboardingRoot(onNavigate: (NavKey) -> Unit, viewModel: OnboardingViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val privacyPolicyUrl = stringResource(R.string.link_privacy_policy) + val termsUrl = stringResource(R.string.link_terms) + val pickRecordingArchive = rememberRecordingArchivePicker( + onResult = viewModel::handleRecordingArchiveResult, + onFailure = viewModel::reportFailure, + ) + LaunchedEffect(viewModel, context, privacyPolicyUrl, termsUrl) { + viewModel.effects.collect { effect -> + when (effect) { + OnboardingEffect.PickRecordingArchive -> pickRecordingArchive() + + OnboardingEffect.OpenPrivacyPolicy -> { + openWebPage(context, privacyPolicyUrl).onFailure(viewModel::reportFailure) + } + + OnboardingEffect.OpenTermsOfUse -> { + openWebPage(context, termsUrl).onFailure(viewModel::reportFailure) + } + + OnboardingEffect.RequestBatteryOptimizationExemption -> { + requestBatteryOptimizationExemption(context).onFailure(viewModel::reportFailure) + } + + is OnboardingEffect.Navigate -> onNavigate(effect.route) + } + } + } + OnboardingScreen(state, viewModel::accept) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreen.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreen.kt index fe3229e..0a78fcf 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingViewModel.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingViewModel.kt index 8703738..c47c925 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/onboarding/OnboardingViewModel.kt @@ -1,14 +1,15 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection +import com.tomasrepcik.sensorbox.navigation.MainRoute +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveSelection import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -68,7 +69,7 @@ class OnboardingViewModel @Inject constructor( ) } - fun reportFailure(error: com.tomasrepcik.sensorbox.core.error.AppError) { + fun reportFailure(error: com.tomasrepcik.sensorbox.core.failure.AppError) { appFailures.show(error) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt similarity index 84% rename from app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt index a13409b..7c60275 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.pairedrecording import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService -import com.tomasrepcik.sensorbox.domain.paired.PhoneWatchMessageDispatcher -import com.tomasrepcik.sensorbox.domain.sync.ReceiveWatchFileUseCase +import com.tomasrepcik.sensorbox.measurements.sync.ReceiveWatchFileUseCase +import com.tomasrepcik.sensorbox.pairedrecording.PhoneWatchMessageDispatcher import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PairedRecordingModel.kt similarity index 78% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PairedRecordingModel.kt index 618d361..0a446f2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PairedRecordingModel.kt @@ -1,9 +1,9 @@ -package com.tomasrepcik.sensorbox.domain.paired +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.recording.StartedPhoneRecording -import com.tomasrepcik.sensorbox.domain.recording.toWatchRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.recording.setup.StartedPhoneRecording +import com.tomasrepcik.sensorbox.recording.setup.toWatchRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand internal sealed interface PairedRecordingState { data object Idle : PairedRecordingState diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneRecordingSessionObserver.kt similarity index 85% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneRecordingSessionObserver.kt index b5ad00f..15cb519 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneRecordingSessionObserver.kt @@ -1,12 +1,12 @@ -package com.tomasrepcik.sensorbox.domain.paired +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingSessionControl +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.recording.PhoneRecordingSessionControl import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopped import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchCommandHandler.kt similarity index 70% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchCommandHandler.kt index 33a4138..b3c4d42 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchCommandHandler.kt @@ -1,21 +1,21 @@ -package com.tomasrepcik.sensorbox.domain.paired +package com.tomasrepcik.sensorbox.pairedrecording import android.content.Context import android.content.Intent -import com.tomasrepcik.sensorbox.activities.MainActivity -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 -import com.tomasrepcik.sensorbox.domain.recording.PeerRecordingControl -import com.tomasrepcik.sensorbox.domain.sensors.ReceiveWatchSensorsUseCase -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome +import com.tomasrepcik.sensorbox.bootstrap.MainActivity +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.recording.PeerRecordingControl +import com.tomasrepcik.sensorbox.recording.sources.ReceiveWatchSensorsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOperation +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchMessageDispatcher.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchMessageDispatcher.kt similarity index 54% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchMessageDispatcher.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchMessageDispatcher.kt index ee2537f..b8dfbe5 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchMessageDispatcher.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/PhoneWatchMessageDispatcher.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.domain.paired +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec import javax.inject.Inject class PhoneWatchMessageDispatcher @Inject constructor( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt similarity index 58% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt index 48f0d09..1890def 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandExchange -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/platform/PhonePlatformActions.kt b/app/src/main/java/com/tomasrepcik/sensorbox/platform/PhonePlatformActions.kt new file mode 100644 index 0000000..29d31ee --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/platform/PhonePlatformActions.kt @@ -0,0 +1,156 @@ +package com.tomasrepcik.sensorbox.platform + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.core.net.toUri +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveSelection + +@Composable +fun rememberRecordingArchivePicker( + onResult: (RecordingArchiveSelection) -> Unit, + onFailure: (AppError) -> Unit, +): () -> Unit { + val currentOnResult = rememberUpdatedState(onResult) + val currentOnFailure = rememberUpdatedState(onFailure) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val selection = if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + RecordingArchiveSelection.Selected(uri.toString(), result.data?.flags ?: 0) + } ?: RecordingArchiveSelection.Cancelled + } else { + RecordingArchiveSelection.Cancelled + } + currentOnResult.value(selection) + } + return { + appResult(AppErrorCode.EXTERNAL_ACTION, "Open recording archive picker") { + launcher.launch(recordingArchiveIntent()) + }.onFailure(currentOnFailure.value) + } +} + +@Composable +fun rememberPermissionRequest(onResult: () -> Unit, onFailure: (AppError) -> Unit): (Set) -> Unit { + val currentOnResult = rememberUpdatedState(onResult) + val currentOnFailure = rememberUpdatedState(onFailure) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + currentOnResult.value() + } + return { permissions -> + appResult(AppErrorCode.PERMISSION, "Request app permissions") { + launcher.launch(permissions.toTypedArray()) + }.onFailure(currentOnFailure.value) + } +} + +@Composable +fun rememberLocationPreviewPermissionRequest(onResult: () -> Unit, onFailure: (AppError) -> Unit): () -> Unit { + val currentOnResult = rememberUpdatedState(onResult) + val currentOnFailure = rememberUpdatedState(onFailure) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + currentOnResult.value() + } + return { + appResult(AppErrorCode.PERMISSION, "Request location preview permission") { + launcher.launch( + arrayOf( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + }.onFailure(currentOnFailure.value) + } +} + +fun openWebPage(context: Context, url: String): AppResult = + launchExternalIntent(context, Intent(Intent.ACTION_VIEW, url.toUri()), "Open web page") + +@SuppressLint("BatteryLife") +fun requestBatteryOptimizationExemption(context: Context): AppResult { + val request = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri(), + ) + return launchExternalIntent(context, request, "Request battery optimization exemption").fold( + onSuccess = { AppResult.success(Unit) }, + onFailure = { + launchExternalIntent( + context, + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + "Open battery optimization settings", + ) + }, + ) +} + +fun shareDiagnosticsText(context: Context, text: String): AppResult { + val intent = Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.diagnostics_title)) + .putExtra(Intent.EXTRA_TEXT, text) + return launchShareIntent(context, intent) +} + +fun shareDiagnosticsFile( + context: Context, + contentUri: String, + displayName: String, + mimeType: String, +): AppResult { + val uri = contentUri.toUri() + val intent = Intent(Intent.ACTION_SEND) + .setType(mimeType) + .putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.diagnostics_title)) + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.clipData = ClipData.newUri(context.contentResolver, displayName, uri) + return launchShareIntent(context, intent) +} + +fun copyDiagnostics(context: Context, text: String): AppResult = appResult( + AppErrorCode.EXTERNAL_ACTION, + "Copy diagnostics", +) { + context.getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(context.getString(R.string.diagnostics_title), text), + ) + Toast.makeText(context, R.string.diagnostics_copied, Toast.LENGTH_LONG).show() +} + +fun showDiagnosticsCleared(context: Context) { + Toast.makeText(context, R.string.diagnostics_cleared, Toast.LENGTH_LONG).show() +} + +private fun launchShareIntent(context: Context, intent: Intent): AppResult = launchExternalIntent( + context, + Intent.createChooser(intent, context.getString(R.string.diagnostics_share)), + "Share diagnostics", +) + +private fun launchExternalIntent(context: Context, intent: Intent, operation: String): AppResult = appResult( + AppErrorCode.EXTERNAL_ACTION, + operation, +) { + context.startActivity(intent) +} + +private fun recordingArchiveIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) +} 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 deleted file mode 100644 index e0a0f9f..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.navigation3.runtime.NavKey -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState -import kotlinx.serialization.Serializable - -@Serializable -enum class MainRoute : NavKey { - ONBOARDING, - RECORD, - ACTIVE_RECORDING, - SENSOR_DETAILS, - SENSOR_PREVIEW, - RECORDING_SETUP, - MEASUREMENTS, - MEASUREMENT_DETAILS, - MEASUREMENT_FILE, - SETTINGS, - DIAGNOSTICS, - LICENSES, - PRIVACY, -} - -data class MainState( - val route: MainRoute = MainRoute.ONBOARDING, - val session: RecordingSessionState = RecordingSessionState.Idle, - val keepScreenAwake: Boolean = false, - val hasLoadedPreferences: Boolean = false, - val visibleFailureCode: AppErrorCode? = null, -) 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 deleted file mode 100644 index 39679e8..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserContract.kt +++ /dev/null @@ -1,135 +0,0 @@ -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/MeasurementBrowserViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt deleted file mode 100644 index 31edd14..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -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, - private val appFailures: AppFailureStore, -) : 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), - ) - appFailures.show(error) - }, - ) - } - } - - 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), - ) - appFailures.show(error) - }, - ) - } - } - - 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), - ) - appFailures.show(error) - }, - ) - } - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt deleted file mode 100644 index cea0833..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.R - -@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) - } - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt deleted file mode 100644 index 872ae93..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry - -@Composable -fun MeasurementFileScreen( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val file = state.selectedFile - val sensorMetadata = file?.let { selectedFile -> - state.selectedMeasurement?.sensorMetadataByFile?.get(selectedFile.id) - }.orEmpty() - SensorBoxBackScreen( - title = file?.name ?: stringResource(R.string.measurement_file), - onBack = onBack, - modifier = modifier, - ) { - measurementFileItems(state.selectedFileContent, sensorMetadata, state.sensorChartTimeWindow, onIntent) - } -} - -private fun LazyListScope.measurementFileItems( - content: MeasurementFileContent?, - sensorMetadata: List, - timeWindow: SensorChartTimeWindow, - onIntent: (MeasurementBrowserIntent) -> Unit, -) { - when (content) { - is MeasurementFileContent.SensorSeries -> sensorSeriesItems(content, sensorMetadata, timeWindow, onIntent) - is MeasurementFileContent.GpsCoordinates -> gpsCoordinateItems(content) - is MeasurementFileContent.Text -> item { StoredText(content) } - null -> item { ArchiveError() } - } -} - -private fun LazyListScope.sensorSeriesItems( - content: MeasurementFileContent.SensorSeries, - metadata: List, - timeWindow: SensorChartTimeWindow, - onIntent: (MeasurementBrowserIntent) -> Unit, -) { - if (metadata.isNotEmpty()) { - item { SensorBoxSettingsSection(stringResource(R.string.sensor_details)) } - metadata.forEach { entry -> item { MetadataRow(entry) } } - } - item { SensorSeriesSummary(content) } - item { StoredSensorChart(content.columns, content.samples, timeWindow, onIntent) } -} - -private fun LazyListScope.gpsCoordinateItems(content: MeasurementFileContent.GpsCoordinates) { - item { CoordinateSummary(content) } - content.coordinates.forEachIndexed { index, coordinate -> item { CoordinateRow(index + 1, coordinate) } } -} 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 deleted file mode 100644 index 3abc2cb..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData -import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState - -enum class RecordingMessage { - NONE, - PICK_AT_LEAST_ONE_SOURCE, - RECORDING_ARCHIVE_REQUIRED, - PERMISSION_REQUIRED, - WATCH_PERMISSION_REQUIRED, - RECORDING_FAILED, -} - -enum class RecordingDevice { - PHONE, - WATCH, -} - -data class RecordingState( - val sensors: List = emptyList(), - val watchSensors: List = emptyList(), - val detailsSensorType: Int? = null, - val detailsDevice: RecordingDevice = RecordingDevice.PHONE, - val selectedSensorIds: Set = emptySet(), - val includesGps: Boolean = false, - val selectedWatchSensorIds: Set = emptySet(), - val watchIncludesGps: Boolean = false, - val customMeasurementName: String = "", - val startDelaySeconds: Int = 0, - val durationSeconds: Int = 0, - val notes: String = "", - val alarmOffsets: String = "", - val activityRecognition: Boolean = false, - val activityRecognitionPeriodSeconds: Int = 30, - val significantMotion: Boolean = false, - val recordingArchivePath: String? = null, - val preferences: AppPreferences = AppPreferences(), - val session: RecordingSessionState = RecordingSessionState.Idle, - val elapsedSeconds: Long = 0, - val isWatchConnected: Boolean = false, - val isStarting: Boolean = false, - val startCountdownSeconds: Int? = null, - val message: RecordingMessage = RecordingMessage.NONE, - val errorCode: AppErrorCode? = null, - val gpsPreview: GpsPreviewData = GpsPreviewData(hasPermission = false), - val sensorPreview: SensorPreviewData = SensorPreviewData(isAvailable = true), -) { - fun toRecordingSetup() = RecordingSetup( - sensorIds = selectedSensorIds, - includesGps = includesGps, - samplingPeriodIndex = preferences.recording.sensorSamplingPeriod, - stopOnLowBattery = preferences.recording.stopRecordingOnLowBattery, - useWakeLock = preferences.recording.useWakeLock, - gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, - watchSensorIds = selectedWatchSensorIds, - watchIncludesGps = watchIncludesGps, - customName = customMeasurementName, - durationSeconds = durationSeconds.coerceAtLeast(0), - notes = notes.lines().map(String::trim).filter(String::isNotEmpty), - alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') - .mapNotNull(String::toIntOrNull).filter { it >= 0 }, - activityRecognition = activityRecognition, - activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, - significantMotion = significantMotion, - ) -} - -sealed interface RecordingIntent { - data class Navigate(val route: MainRoute) : RecordingIntent - data class ToggleSensor(val sensorId: Int) : RecordingIntent - data class ToggleWatchSensor(val sensorId: Int) : RecordingIntent - data class OpenSensorDetails(val sensorType: Int?, val device: RecordingDevice = RecordingDevice.PHONE) : - RecordingIntent - data object ToggleGps : RecordingIntent - data object ToggleWatchGps : RecordingIntent - data object ChooseRecordingArchive : RecordingIntent - data object OpenRecordingSetup : RecordingIntent - data object ReturnToSensorSelection : RecordingIntent - data object StartRecording : RecordingIntent - data object StopRecording : RecordingIntent - data object ClearMessage : RecordingIntent - data class SetCustomMeasurementName(val value: String) : RecordingIntent - data class SetStartDelay(val seconds: Int) : RecordingIntent - data class SetDuration(val seconds: Int) : RecordingIntent - data class SetNotes(val value: String) : RecordingIntent - data class SetAlarmOffsets(val value: String) : RecordingIntent - data class SetActivityRecognition(val enabled: Boolean) : RecordingIntent - data class SetActivityRecognitionPeriod(val seconds: Int) : RecordingIntent - data class SetSignificantMotionRecording(val enabled: Boolean) : RecordingIntent - data class AddAnnotation(val text: String) : RecordingIntent - data class SetSamplingPeriod(val index: Int) : RecordingIntent - data class SetStopOnLowBattery(val enabled: Boolean) : RecordingIntent - data class SetWakeLock(val enabled: Boolean) : RecordingIntent - data class SetKeepScreenAwake(val enabled: Boolean) : RecordingIntent - data class SetGpsInterval(val seconds: Int) : RecordingIntent - data class SetGpsDistance(val meters: Int) : RecordingIntent - data object StartGpsPreview : RecordingIntent - data class StartSensorPreview(val sensorType: Int) : RecordingIntent - data object StopPreview : RecordingIntent - data object RequestLocationPreviewPermission : RecordingIntent -} - -sealed interface RecordingEffect { - data object PickRecordingArchive : RecordingEffect - data class RequestPermissions(val permissions: Set) : RecordingEffect - data object RequestLocationPreviewPermission : RecordingEffect - data class Navigate(val route: MainRoute) : RecordingEffect -} - -data class RecordingNext(val state: RecordingState, val effect: RecordingEffect? = null) - -object RecordingReducer { - fun reduce(state: RecordingState, intent: RecordingIntent): RecordingNext = reduceNavigation(state, intent) - ?: reduceSelection(state, intent) - ?: reduceConfiguration(state, intent) - - fun recordingStartRequested(state: RecordingState, countdownSeconds: Int): RecordingState = state.copy( - isStarting = true, - startCountdownSeconds = countdownSeconds.takeIf { it > 0 }, - message = RecordingMessage.NONE, - errorCode = null, - ) - - fun recordingCountdownChanged(state: RecordingState, seconds: Int?): RecordingState = state.copy( - startCountdownSeconds = seconds, - ) - - fun recordingStartFailed(state: RecordingState): RecordingState = state.copy( - isStarting = false, - startCountdownSeconds = null, - ) - - fun recordingSessionChanged(state: RecordingState, session: RecordingSessionState): RecordingState = state.copy( - session = session, - elapsedSeconds = 0, - isStarting = if (session is RecordingSessionState.Running) false else state.isStarting, - startCountdownSeconds = if (session is RecordingSessionState.Running) null else state.startCountdownSeconds, - ) - - private fun reduceNavigation(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { - RecordingIntent.ChooseRecordingArchive -> RecordingNext(state, RecordingEffect.PickRecordingArchive) - - is RecordingIntent.Navigate -> RecordingNext(state, RecordingEffect.Navigate(intent.route)) - - is RecordingIntent.OpenSensorDetails -> RecordingNext( - state.copy( - detailsSensorType = intent.sensorType, - detailsDevice = intent.device, - ), - RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), - ) - - RecordingIntent.OpenRecordingSetup -> RecordingNext( - state.copy(message = RecordingMessage.NONE), - RecordingEffect.Navigate(MainRoute.RECORDING_SETUP), - ) - - RecordingIntent.ReturnToSensorSelection -> RecordingNext( - state.copy(message = RecordingMessage.NONE), - RecordingEffect.Navigate(MainRoute.RECORD), - ) - - else -> null - } - - private fun reduceSelection(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { - RecordingIntent.ClearMessage -> RecordingNext(state.copy(message = RecordingMessage.NONE, errorCode = null)) - RecordingIntent.ToggleGps -> RecordingNext(state.copy(includesGps = !state.includesGps)) - RecordingIntent.ToggleWatchGps -> RecordingNext(state.copy(watchIncludesGps = !state.watchIncludesGps)) - is RecordingIntent.ToggleSensor -> RecordingNext(state.toggleSensor(intent.sensorId)) - is RecordingIntent.ToggleWatchSensor -> RecordingNext(state.toggleWatchSensor(intent.sensorId)) - else -> null - } - - private fun reduceConfiguration(state: RecordingState, intent: RecordingIntent): RecordingNext = when (intent) { - is RecordingIntent.SetCustomMeasurementName -> RecordingNext(state.copy(customMeasurementName = intent.value)) - - is RecordingIntent.SetStartDelay -> RecordingNext( - state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0)), - ) - - is RecordingIntent.SetDuration -> RecordingNext(state.copy(durationSeconds = intent.seconds.coerceAtLeast(0))) - - is RecordingIntent.SetNotes -> RecordingNext(state.copy(notes = intent.value)) - - is RecordingIntent.SetAlarmOffsets -> RecordingNext(state.copy(alarmOffsets = intent.value)) - - is RecordingIntent.SetActivityRecognition -> RecordingNext(state.copy(activityRecognition = intent.enabled)) - - is RecordingIntent.SetActivityRecognitionPeriod -> RecordingNext( - state.copy(activityRecognitionPeriodSeconds = intent.seconds.coerceAtLeast(1)), - ) - - is RecordingIntent.SetSignificantMotionRecording -> RecordingNext( - state.copy(significantMotion = intent.enabled), - ) - - else -> RecordingNext(state) - } - - private fun RecordingState.toggleSensor(sensorId: Int): RecordingState { - val updated = selectedSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) - } - - private fun RecordingState.toggleWatchSensor(sensorId: Int): RecordingState { - val updated = selectedWatchSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedWatchSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) - } -} 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 deleted file mode 100644 index 31fb4ee..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt +++ /dev/null @@ -1,350 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -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.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent -import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection -import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase -import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState -import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -@Suppress("TooManyFunctions") -class RecordingViewModel @Inject constructor( - private val preferencesRepository: AppPreferencesRepository, - private val availableSources: AvailableRecordingSourcesUseCase, - private val recordingArchive: RecordingArchiveRepository, - private val permissions: RecordingPermissionsUseCase, - private val recording: RecordingControlUseCase, - private val sessionStore: RecordingSessionStore, - private val elapsedRealtimeClock: ElapsedRealtimeClock, - private val appFailures: AppFailureStore, - private val devicePreview: DevicePreviewRepository, -) : ViewModel() { - private val mutableState = MutableStateFlow(initialState()) - private val mutableEffects = Channel(Channel.BUFFERED) - private var elapsedJob: Job? = null - private var startJob: Job? = null - private var previewJob: Job? = null - - val state: StateFlow = mutableState.asStateFlow() - val effects = mutableEffects.receiveAsFlow() - - init { - observePreferences() - observeRecordingSession() - observeRecordingFailures() - observeAvailableSources() - } - - fun accept(intent: RecordingIntent) { - intent.toPreferencesIntent()?.let { - updatePreference(it) - return - } - when (intent) { - RecordingIntent.StartRecording -> startRecording() - - RecordingIntent.StopRecording -> stopRecording() - - is RecordingIntent.AddAnnotation -> recording.annotate(intent.text).showFailure() - - RecordingIntent.StartGpsPreview -> startGpsPreview() - - is RecordingIntent.StartSensorPreview -> startSensorPreview(intent.sensorType) - - RecordingIntent.StopPreview -> stopPreview() - - RecordingIntent.RequestLocationPreviewPermission -> - mutableEffects.trySend(RecordingEffect.RequestLocationPreviewPermission) - - else -> reduce(intent) - } - } - - private fun RecordingIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { - is RecordingIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) - is RecordingIntent.SetStopOnLowBattery -> AppPreferencesIntent.SetStopOnLowBattery(enabled) - is RecordingIntent.SetWakeLock -> AppPreferencesIntent.SetWakeLock(enabled) - is RecordingIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) - is RecordingIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) - is RecordingIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) - else -> null - } - - fun handleRecordingArchiveResult(selection: RecordingArchiveSelection) { - if (selection is RecordingArchiveSelection.Cancelled) return - - val persisted = recordingArchive.select(selection as RecordingArchiveSelection.Selected) - persisted.errorOrNull()?.let(appFailures::show) - mutableState.value = state.value.copy( - recordingArchivePath = readRecordingArchivePath(), - message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.RECORDING_ARCHIVE_REQUIRED, - errorCode = persisted.errorOrNull()?.code, - ) - } - - fun reportFailure(error: AppError) { - appFailures.show(error) - } - - fun handlePermissionResult() { - val request = state.value.toRecordingSetup() - val missing = permissions.missingPermissions(request) - if (missing.isEmpty()) { - startRecording() - } else { - showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) - } - } - - fun handleLocationPreviewPermissionResult() { - startGpsPreview() - } - - private fun startGpsPreview() { - previewJob?.cancel() - val preferences = state.value.preferences.recording - previewJob = viewModelScope.launch { - devicePreview.observeGps( - preferences.gpsIntervalSeconds, - preferences.gpsMinDistanceMeters, - ).collect { result -> - when (result) { - is AppResult.Success -> mutableState.value = state.value.copy(gpsPreview = result.value) - is AppResult.Failure -> appFailures.show(result.error) - } - } - } - } - - private fun startSensorPreview(sensorType: Int) { - previewJob?.cancel() - previewJob = viewModelScope.launch { - devicePreview.observeSensor(sensorType).collect { result -> - when (result) { - is AppResult.Success -> mutableState.value = state.value.copy(sensorPreview = result.value) - is AppResult.Failure -> appFailures.show(result.error) - } - } - } - } - - private fun stopPreview() { - previewJob?.cancel() - previewJob = null - } - - private fun initialState() = RecordingState( - sensors = availableSources.current.phoneSensors, - watchSensors = availableSources.current.watchSensors, - isWatchConnected = availableSources.current.isWatchConnected, - recordingArchivePath = readRecordingArchivePath(), - ) - - private fun observePreferences() { - viewModelScope.launch { - preferencesRepository.preferences.collect { result -> - result.fold( - onSuccess = { preferences -> - mutableState.value = state.value.copy( - preferences = preferences, - errorCode = null, - ) - }, - onFailure = { error -> - appFailures.show(error) - }, - ) - } - } - } - - private fun observeRecordingSession() { - viewModelScope.launch { - sessionStore.state.collect { session -> - onSessionChanged(session) - } - } - } - - private fun observeRecordingFailures() { - viewModelScope.launch { - sessionStore.events.collect { event -> - event.result.errorOrNull()?.let(appFailures::show) - } - } - } - - private fun onSessionChanged(session: RecordingSessionState) { - elapsedJob?.cancel() - mutableState.value = RecordingReducer.recordingSessionChanged(state.value, session) - if (session is RecordingSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) - } - - private fun startElapsedTicker(startedAt: Long) { - elapsedJob = viewModelScope.launch { - while (isActive) { - val seconds = (elapsedRealtimeClock.nowMillis() - startedAt).coerceAtLeast(0) / 1_000L - mutableState.value = state.value.copy(elapsedSeconds = seconds) - delay(1_000L) - } - } - } - - private fun observeAvailableSources() { - viewModelScope.launch { - availableSources.observe().catch { cause -> - appFailures.show( - AppError( - AppErrorCode.UNKNOWN, - "Observe available recording sources", - "Recording source observation failed", - cause, - ), - ) - }.collect { sources -> - mutableState.value = state.value.copy( - sensors = sources.phoneSensors, - isWatchConnected = sources.isWatchConnected, - watchSensors = sources.watchSensors, - ) - } - } - } - - private fun reduce(intent: RecordingIntent) { - val next = RecordingReducer.reduce(state.value, intent) - mutableState.value = next.state - next.effect?.let(mutableEffects::trySend) - } - - @Suppress("ReturnCount") - private fun startRecording() { - if (state.value.isStarting) return - val request = state.value.toRecordingSetup() - if (!request.hasAnySource()) { - showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) - return - } - when (val selected = recordingArchive.isSelected()) { - is AppResult.Failure -> { - appFailures.show(selected.error) - return - } - - is AppResult.Success -> if (!selected.value) { - reduce(RecordingIntent.ChooseRecordingArchive) - showMessage(RecordingMessage.RECORDING_ARCHIVE_REQUIRED, AppErrorCode.STORAGE) - return - } - } - requestMissingPermissions(request)?.let { - mutableEffects.trySend(RecordingEffect.RequestPermissions(it)) - mutableState.value = state.value.copy(errorCode = AppErrorCode.PERMISSION) - return - } - startRecordingAfterDelay(request, state.value.startDelaySeconds) - } - - private fun requestMissingPermissions(request: RecordingSetup): Set? { - val missingPermissions = permissions.missingPermissions(request) - return missingPermissions.takeIf(Set::isNotEmpty) - } - - private fun readRecordingArchivePath(): String? = recordingArchive.path().fold( - onSuccess = { it }, - onFailure = { - appFailures.show(it) - null - }, - ) - - private fun startRecordingAfterDelay(request: RecordingSetup, countdownSeconds: Int) { - mutableState.value = RecordingReducer.recordingStartRequested(state.value, countdownSeconds) - startJob = viewModelScope.launch { - waitForStartCountdown(countdownSeconds) - val result = recording.start(request) - if (result.isFailure) { - mutableState.value = RecordingReducer.recordingStartFailed(state.value) - showFailure(result.errorOrNull()) - } - } - } - - private suspend fun waitForStartCountdown(seconds: Int) { - for (remaining in seconds downTo 1) { - mutableState.value = RecordingReducer.recordingCountdownChanged(state.value, remaining) - delay(1_000L) - } - mutableState.value = RecordingReducer.recordingCountdownChanged(state.value, null) - } - - private fun stopRecording() { - if (state.value.isStarting && state.value.session is RecordingSessionState.Idle) { - startJob?.cancel() - startJob = null - mutableState.value = RecordingReducer.recordingStartFailed(state.value) - return - } - viewModelScope.launch { - val result = recording.stop() - if (result.isSuccess) { - mutableEffects.send(RecordingEffect.Navigate(MainRoute.RECORD)) - } else { - result.showFailure() - } - } - } - - private fun updatePreference(intent: AppPreferencesIntent) { - viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } - } - - private fun showMessage(message: RecordingMessage, errorCode: AppErrorCode? = null) { - mutableState.value = state.value.copy(message = message, errorCode = errorCode) - } - - private fun AppResult<*>.showFailure() { - if (isFailure) showFailure(errorOrNull()) - } - - private fun showFailure(error: AppError?) { - when { - error == null -> appFailures.show(AppError(AppErrorCode.UNKNOWN, "Run recording operation")) - - error.code == AppErrorCode.PERMISSION && error.context["source"] == "watch" -> - showMessage(RecordingMessage.WATCH_PERMISSION_REQUIRED, AppErrorCode.PERMISSION) - - error.code == AppErrorCode.PERMISSION -> - showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) - - else -> appFailures.show(error) - } - } - - private fun RecordingSetup.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || - watchSensorIds.isNotEmpty() || watchIncludesGps || activityRecognition || significantMotion -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt deleted file mode 100644 index 3164be7..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.compose.runtime.Composable - -@Composable -internal 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, -) { - when (route) { - MainRoute.ONBOARDING -> OnboardingScreen(onboardingState, onOnboardingIntent) - - MainRoute.ACTIVE_RECORDING, - MainRoute.SENSOR_DETAILS, - MainRoute.SENSOR_PREVIEW, - MainRoute.RECORDING_SETUP, - MainRoute.RECORD, - -> RecordingRouteContent(route, recordingState, onRecordingIntent, onBack) - - MainRoute.MEASUREMENTS, - MainRoute.MEASUREMENT_DETAILS, - MainRoute.MEASUREMENT_FILE, - -> MeasurementBrowserRoute(route, measurementBrowserState, onMeasurementBrowserIntent, onBack) - - MainRoute.SETTINGS, - MainRoute.DIAGNOSTICS, - MainRoute.LICENSES, - MainRoute.PRIVACY, - -> SettingsRouteContent(route, settingsState, onSettingsIntent, onBack) - } -} - -@Composable -private fun RecordingRouteContent( - route: MainRoute, - state: RecordingState, - onIntent: (RecordingIntent) -> Unit, - onBack: () -> Unit, -) { - FullScreen { modifier -> - when (route) { - MainRoute.ACTIVE_RECORDING -> ActiveRecordingScreen(state, onIntent, modifier) - - MainRoute.SENSOR_DETAILS -> SensorDetailsScreen( - state, - onBack, - { onIntent(RecordingIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, - onIntent, - modifier, - ) - - MainRoute.SENSOR_PREVIEW -> SensorPreviewScreen(state, onIntent, onBack, modifier) - - MainRoute.RECORDING_SETUP -> RecordingSetupScreen( - state = state, - onIntent = onIntent, - modifier = modifier, - onBack = { onIntent(RecordingIntent.ReturnToSensorSelection) }, - ) - - MainRoute.RECORD -> RecordScreen(state, onIntent, modifier) - - else -> error("Not a recording route") - } - } -} - -@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 browser route") - } - } -} - -@Composable -private fun SettingsRouteContent( - route: MainRoute, - state: SettingsState, - onIntent: (SettingsIntent) -> Unit, - onBack: () -> Unit, -) { - FullScreen { modifier -> - when (route) { - MainRoute.SETTINGS -> SettingsScreen(state, onIntent, modifier, onBack) - - MainRoute.DIAGNOSTICS -> DiagnosticsLogScreen( - state = state, - onBack = onBack, - onRefresh = { onIntent(SettingsIntent.ViewDiagnostics) }, - modifier = modifier, - ) - - MainRoute.LICENSES -> OpenSourceLicensesScreen(state, onIntent, onBack, modifier) - - MainRoute.PRIVACY -> PrivacyScreen(onBack, modifier) - - else -> error("Not a settings route") - } - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/PhoneRecordingController.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingController.kt similarity index 76% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/PhoneRecordingController.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingController.kt index ec0f64f..0a1c339 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/PhoneRecordingController.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingController.kt @@ -1,17 +1,19 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.content.Intent import androidx.core.content.ContextCompat -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 -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import com.tomasrepcik.sensorbox.core.storage.DocumentStorage -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.recording.setup.StartedPhoneRecording +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -51,7 +53,9 @@ class AndroidPhoneRecordingController @Inject constructor( ) { val folderName = intentFactory.newFolderName(request.customName) val recordingRequest = request.toRecordingRequest(sessionId, folderName) - ContextCompat.startForegroundService(context, intentFactory.create(recordingRequest)) + checkNotNull(ContextCompat.startForegroundService(context, intentFactory.create(recordingRequest))) { + "Recording foreground service is not registered" + } StartedPhoneRecording(sessionId, folderName, recordingRequest.durationMillis) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCase.kt similarity index 82% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCase.kt index b67e652..8b25762 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCase.kt @@ -1,20 +1,21 @@ -package com.tomasrepcik.sensorbox.domain.recording - -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.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent +package com.tomasrepcik.sensorbox.recording + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.domain.paired.PairedRecordingState -import com.tomasrepcik.sensorbox.domain.paired.activeRecording -import com.tomasrepcik.sensorbox.domain.paired.includesWatchRecording -import com.tomasrepcik.sensorbox.domain.paired.toWatchStartCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import com.tomasrepcik.sensorbox.wearoslib.protocol.wasRecordingCommandSent +import com.tomasrepcik.sensorbox.pairedrecording.PairedRecordingState +import com.tomasrepcik.sensorbox.pairedrecording.activeRecording +import com.tomasrepcik.sensorbox.pairedrecording.includesWatchRecording +import com.tomasrepcik.sensorbox.pairedrecording.toWatchStartCommand +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.wasRecordingCommandSent import java.util.UUID import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingDraft.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingDraft.kt new file mode 100644 index 0000000..c1c5963 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingDraft.kt @@ -0,0 +1,55 @@ +package com.tomasrepcik.sensorbox.recording + +import kotlinx.serialization.Serializable + +@Serializable +data class RecordingDraft( + val detailsSensorType: Int? = null, + val detailsDevice: RecordingDevice = RecordingDevice.PHONE, + val selectedSensorIds: Set = emptySet(), + val includesGps: Boolean = false, + val selectedWatchSensorIds: Set = emptySet(), + val watchIncludesGps: Boolean = false, + val customMeasurementName: String = "", + val startDelaySeconds: Int = 0, + val durationSeconds: Int = 0, + val notes: String = "", + val alarmOffsets: String = "", + val activityRecognition: Boolean = false, + val activityRecognitionPeriodSeconds: Int = 30, + val significantMotion: Boolean = false, +) + +fun RecordingState.withDraft(draft: RecordingDraft): RecordingState = copy( + detailsSensorType = draft.detailsSensorType, + detailsDevice = draft.detailsDevice, + selectedSensorIds = draft.selectedSensorIds, + includesGps = draft.includesGps, + selectedWatchSensorIds = draft.selectedWatchSensorIds, + watchIncludesGps = draft.watchIncludesGps, + customMeasurementName = draft.customMeasurementName, + startDelaySeconds = draft.startDelaySeconds, + durationSeconds = draft.durationSeconds, + notes = draft.notes, + alarmOffsets = draft.alarmOffsets, + activityRecognition = draft.activityRecognition, + activityRecognitionPeriodSeconds = draft.activityRecognitionPeriodSeconds, + significantMotion = draft.significantMotion, +) + +fun RecordingState.toDraft() = RecordingDraft( + detailsSensorType = detailsSensorType, + detailsDevice = detailsDevice, + selectedSensorIds = selectedSensorIds, + includesGps = includesGps, + selectedWatchSensorIds = selectedWatchSensorIds, + watchIncludesGps = watchIncludesGps, + customMeasurementName = customMeasurementName, + startDelaySeconds = startDelaySeconds, + durationSeconds = durationSeconds, + notes = notes, + alarmOffsets = alarmOffsets, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingMessage.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingMessage.kt index 477965c..7f4a56d 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingMessage.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModel.kt new file mode 100644 index 0000000..4c86b8c --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModel.kt @@ -0,0 +1,77 @@ +package com.tomasrepcik.sensorbox.recording + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.preferences.AppPreferences +import com.tomasrepcik.sensorbox.recording.preview.GpsPreviewData +import com.tomasrepcik.sensorbox.recording.preview.SensorPreviewData +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import kotlinx.serialization.Serializable + +enum class RecordingMessage { + NONE, + PICK_AT_LEAST_ONE_SOURCE, + RECORDING_ARCHIVE_REQUIRED, + PERMISSION_REQUIRED, + WATCH_PERMISSION_REQUIRED, + RECORDING_FAILED, +} + +@Serializable +enum class RecordingDevice { + PHONE, + WATCH, +} + +interface RecordingScreenEffect + +data class RecordingState( + val sensors: List = emptyList(), + val watchSensors: List = emptyList(), + val detailsSensorType: Int? = null, + val detailsDevice: RecordingDevice = RecordingDevice.PHONE, + val selectedSensorIds: Set = emptySet(), + val includesGps: Boolean = false, + val selectedWatchSensorIds: Set = emptySet(), + val watchIncludesGps: Boolean = false, + val customMeasurementName: String = "", + val startDelaySeconds: Int = 0, + val durationSeconds: Int = 0, + val notes: String = "", + val alarmOffsets: String = "", + val activityRecognition: Boolean = false, + val activityRecognitionPeriodSeconds: Int = 30, + val significantMotion: Boolean = false, + val recordingArchivePath: String? = null, + val preferences: AppPreferences = AppPreferences(), + val session: RecordingSessionState = RecordingSessionState.Idle, + val elapsedSeconds: Long = 0, + val isWatchConnected: Boolean = false, + val isStarting: Boolean = false, + val startCountdownSeconds: Int? = null, + val message: RecordingMessage = RecordingMessage.NONE, + val errorCode: AppErrorCode? = null, + val gpsPreview: GpsPreviewData = GpsPreviewData(hasPermission = false), + val sensorPreview: SensorPreviewData = SensorPreviewData(isAvailable = true), +) { + fun toRecordingSetup() = RecordingSetup( + sensorIds = selectedSensorIds, + includesGps = includesGps, + samplingPeriodIndex = preferences.recording.sensorSamplingPeriod, + stopOnLowBattery = preferences.recording.stopRecordingOnLowBattery, + useWakeLock = preferences.recording.useWakeLock, + gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, + watchSensorIds = selectedWatchSensorIds, + watchIncludesGps = watchIncludesGps, + customName = customMeasurementName, + durationSeconds = durationSeconds.coerceAtLeast(0), + notes = notes.lines().map(String::trim).filter(String::isNotEmpty), + alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') + .mapNotNull(String::toIntOrNull).filter { it >= 0 }, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt similarity index 53% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt index 492ca35..283a37f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt @@ -1,30 +1,30 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.data.measurements.AndroidMeasurementRepository -import com.tomasrepcik.sensorbox.domain.diagnostics.AndroidDiagnosticsShareFilePreparer -import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer -import com.tomasrepcik.sensorbox.domain.licenses.AndroidOpenSourceLicenseRepository -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository -import com.tomasrepcik.sensorbox.domain.preview.AndroidDevicePreviewRepository -import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository -import com.tomasrepcik.sensorbox.domain.recording.AndroidPhoneRecordingController -import com.tomasrepcik.sensorbox.domain.recording.DefaultRecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.DocumentRecordingArchiveRepository -import com.tomasrepcik.sensorbox.domain.recording.PeerRecordingControl -import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingSessionControl -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase -import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.GetAvailableSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.ReceiveWatchSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.RecordingSourceAvailability -import com.tomasrepcik.sensorbox.presentation.main.AndroidElapsedRealtimeClock -import com.tomasrepcik.sensorbox.presentation.main.ElapsedRealtimeClock +import com.tomasrepcik.sensorbox.about.AndroidOpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.about.OpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.diagnostics.AndroidDiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.measurements.storage.AndroidMeasurementRepository +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementRepository +import com.tomasrepcik.sensorbox.recording.AndroidPhoneRecordingController +import com.tomasrepcik.sensorbox.recording.DefaultRecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.PeerRecordingControl +import com.tomasrepcik.sensorbox.recording.PhoneRecordingController +import com.tomasrepcik.sensorbox.recording.PhoneRecordingSessionControl +import com.tomasrepcik.sensorbox.recording.RecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.RecordingPermissionUseCase +import com.tomasrepcik.sensorbox.recording.RecordingPermissionsUseCase +import com.tomasrepcik.sensorbox.recording.active.AndroidElapsedRealtimeClock +import com.tomasrepcik.sensorbox.recording.active.ElapsedRealtimeClock +import com.tomasrepcik.sensorbox.recording.archive.DocumentRecordingArchiveRepository +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.recording.preview.AndroidDevicePreviewRepository +import com.tomasrepcik.sensorbox.recording.preview.DevicePreviewRepository +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSourcesUseCase +import com.tomasrepcik.sensorbox.recording.sources.AvailableSensorsUseCase +import com.tomasrepcik.sensorbox.recording.sources.GetAvailableSensorsUseCase +import com.tomasrepcik.sensorbox.recording.sources.ReceiveWatchSensorsUseCase +import com.tomasrepcik.sensorbox.recording.sources.RecordingSourceAvailability import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCase.kt similarity index 87% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCase.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCase.kt index fdb7116..ee6a5c9 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCase.kt @@ -1,10 +1,12 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest +import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.core.content.ContextCompat +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -21,6 +23,7 @@ class RecordingPermissionUseCase @Inject constructor(@ApplicationContext private ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } +@SuppressLint("InlinedApi") internal fun requiredRecordingPermissions(request: RecordingSetup, sdkInt: Int): Set = buildSet { if (sdkInt >= 33) add(Manifest.permission.POST_NOTIFICATIONS) if (request.includesGps) add(Manifest.permission.ACCESS_FINE_LOCATION) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingComponents.kt similarity index 91% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingComponents.kt index eff4549..4ddec3d 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import android.content.res.Resources import androidx.compose.animation.AnimatedVisibility @@ -41,7 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -49,11 +49,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.design.SensorBoxDangerButton +import com.tomasrepcik.sensorbox.design.SensorBoxPrimaryButton +import com.tomasrepcik.sensorbox.design.SensorBoxRecording +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.recording.RecordingState import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxRecording @Composable -internal fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { +internal fun AnnotationEditor(onIntent: (ActiveRecordingIntent) -> Unit) { var annotation by remember { mutableStateOf("") } Column( Modifier.fillMaxWidth().padding(vertical = 12.dp), @@ -75,7 +79,7 @@ internal fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { enabled = annotation.isNotBlank(), onClick = { annotation.trim().takeIf(String::isNotEmpty)?.let { - onIntent(RecordingIntent.AddAnnotation(it)) + onIntent(ActiveRecordingIntent.AddAnnotation(it)) annotation = "" } }, @@ -163,7 +167,7 @@ internal fun RecordingTimer(elapsedSeconds: Long, folderName: String) { @Composable internal fun RecordingSummary(state: RecordingState, session: RecordingSessionState.Running) { var expanded by remember { mutableStateOf(false) } - val sources = recordingSourceNames(state, session, LocalContext.current.resources) + val sources = recordingSourceNames(state, session, LocalResources.current) Column(Modifier.fillMaxWidth()) { SourceListToggle(sourceCount = sources.size, expanded = expanded, onClick = { expanded = !expanded }) AnimatedVisibility( @@ -232,14 +236,14 @@ private fun recordingSourceNames( } @Composable -internal fun ActiveRecordingTopBar(onIntent: (RecordingIntent) -> Unit) { +internal fun ActiveRecordingTopBar(onIntent: (ActiveRecordingIntent) -> Unit) { Box( Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), contentAlignment = Alignment.Center, ) { SensorBoxDangerButton( label = stringResource(R.string.stop_and_save), - onClick = { onIntent(RecordingIntent.StopRecording) }, + onClick = { onIntent(ActiveRecordingIntent.StopRecording) }, modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), ) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingContract.kt new file mode 100644 index 0000000..67854ce --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingContract.kt @@ -0,0 +1,23 @@ +package com.tomasrepcik.sensorbox.recording.active + +import com.tomasrepcik.sensorbox.recording.RecordingScreenEffect +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState + +sealed interface ActiveRecordingIntent { + data object StopRecording : ActiveRecordingIntent + data class AddAnnotation(val text: String) : ActiveRecordingIntent +} + +sealed interface ActiveRecordingEffect : RecordingScreenEffect { + data object RecordingStopped : ActiveRecordingEffect +} + +object RecordingSessionStateReducer { + fun sessionChanged(state: RecordingState, session: RecordingSessionState): RecordingState = state.copy( + session = session, + elapsedSeconds = 0, + isStarting = if (session is RecordingSessionState.Running) false else state.isStarting, + startCountdownSeconds = if (session is RecordingSessionState.Running) null else state.startCountdownSeconds, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingRoot.kt new file mode 100644 index 0000000..f71a4c7 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingRoot.kt @@ -0,0 +1,25 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.navigation.MainRoute + +@Composable +fun ActiveRecordingRoot(onNavigate: (NavKey) -> Unit, viewModel: ActiveRecordingViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + ActiveRecordingEffect.RecordingStopped -> onNavigate(MainRoute.RECORD) + } + } + } + FullScreen { modifier -> + ActiveRecordingScreen(state, viewModel::accept, modifier) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingScreen.kt similarity index 78% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingScreen.kt index 22a485b..a037205 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -12,10 +12,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsSection +import com.tomasrepcik.sensorbox.recording.RecordingState import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState @Composable -fun ActiveRecordingScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { +fun ActiveRecordingScreen( + state: RecordingState, + onIntent: (ActiveRecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { val session = state.session as? RecordingSessionState.Running ?: return Column(modifier.fillMaxSize()) { RecordingHeader(Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp)) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingViewModel.kt new file mode 100644 index 0000000..d17764f --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ActiveRecordingViewModel.kt @@ -0,0 +1,97 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.recording.RecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ActiveRecordingViewModel @Inject constructor( + private val recording: RecordingControlUseCase, + private val sessionStore: RecordingSessionStore, + private val elapsedRealtimeClock: ElapsedRealtimeClock, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(RecordingState(session = sessionStore.state.value)) + private val mutableEffects = Channel(Channel.BUFFERED) + private var elapsedJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observeRecordingSession() + observeRecordingStops() + } + + fun accept(intent: ActiveRecordingIntent) { + when (intent) { + ActiveRecordingIntent.StopRecording -> stopRecording() + is ActiveRecordingIntent.AddAnnotation -> recording.annotate(intent.text).showFailure() + } + } + + private fun observeRecordingSession() { + viewModelScope.launch { + sessionStore.state.collect(::onSessionChanged) + } + } + + private fun observeRecordingStops() { + viewModelScope.launch { + sessionStore.events.collect { event -> + event.result.errorOrNull()?.let(appFailures::show) + } + } + } + + private fun onSessionChanged(session: RecordingSessionState) { + elapsedJob?.cancel() + mutableState.value = RecordingSessionStateReducer.sessionChanged(state.value, session) + if (session is RecordingSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) + } + + private fun startElapsedTicker(startedAt: Long) { + elapsedJob = viewModelScope.launch { + while (isActive) { + val seconds = (elapsedRealtimeClock.nowMillis() - startedAt).coerceAtLeast(0) / 1_000L + mutableState.value = state.value.copy(elapsedSeconds = seconds) + delay(1_000L) + } + } + } + + private fun stopRecording() { + viewModelScope.launch { + val result = recording.stop() + if (result.isSuccess) { + mutableEffects.send(ActiveRecordingEffect.RecordingStopped) + } else { + result.showFailure() + } + } + } + + private fun AppResult<*>.showFailure() { + if (isFailure) { + appFailures.show(errorOrNull() ?: AppError(AppErrorCode.UNKNOWN, "Run active recording operation")) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ElapsedRealtimeClock.kt similarity index 84% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ElapsedRealtimeClock.kt index 669b523..ed4588a 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ElapsedRealtimeClock.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/ElapsedRealtimeClock.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import android.os.SystemClock import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContent.kt similarity index 58% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContent.kt index 2240913..b20190f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContent.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column @@ -13,18 +13,27 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.navigation.MainRoute +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingMessageText +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.setup.GpsRow +import com.tomasrepcik.sensorbox.recording.setup.RecordHeader +import com.tomasrepcik.sensorbox.recording.setup.SensorSourceItem +import com.tomasrepcik.sensorbox.recording.setup.SourceDivider +import com.tomasrepcik.sensorbox.recording.setup.WearSectionHeader +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor @Composable @OptIn(ExperimentalFoundationApi::class) -internal fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun RecordContent(state: RecordingState, onIntent: (RecordIntent) -> Unit) { LazyColumn(contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 24.dp)) { stickyHeader { Surface(color = MaterialTheme.colorScheme.background) { Column { RecordHeader( - onMeasurements = { onIntent(RecordingIntent.Navigate(MainRoute.MEASUREMENTS)) }, - onOptions = { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) }, + onMeasurements = { onIntent(RecordIntent.Navigate(MainRoute.MEASUREMENTS)) }, + onOptions = { onIntent(RecordIntent.Navigate(MainRoute.SETTINGS)) }, ) Spacer(Modifier.height(18.dp)) } @@ -36,12 +45,12 @@ internal fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> } } -private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (RecordIntent) -> Unit) { item { GpsRow( selected = state.includesGps, - onToggle = { onIntent(RecordingIntent.ToggleGps) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, + onToggle = { onIntent(RecordIntent.ToggleGps) }, + onInfo = { onIntent(RecordIntent.OpenSensorDetails(null)) }, ) } item { SourceDivider() } @@ -49,18 +58,18 @@ private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (Rec SensorSourceItem( sensor = sensor, selected = sensor.type in state.selectedSensorIds, - onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, + onToggle = { onIntent(RecordIntent.ToggleSensor(sensor.type)) }, + onInfo = { onIntent(RecordIntent.OpenSensorDetails(sensor.type)) }, ) } } -private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (RecordIntent) -> Unit) { item { WearSectionHeader() } item { GpsRow( selected = state.watchIncludesGps, - onToggle = { onIntent(RecordingIntent.ToggleWatchGps) }, + onToggle = { onIntent(RecordIntent.ToggleWatchGps) }, ) } item { SourceDivider() } @@ -68,8 +77,8 @@ private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (Rec SensorSourceItem( sensor = sensor, selected = sensor.type in state.selectedWatchSensorIds, - onToggle = { onIntent(RecordingIntent.ToggleWatchSensor(sensor.type)) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type, RecordingDevice.WATCH)) }, + onToggle = { onIntent(RecordIntent.ToggleWatchSensor(sensor.type)) }, + onInfo = { onIntent(RecordIntent.OpenSensorDetails(sensor.type, RecordingDevice.WATCH)) }, ) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContract.kt new file mode 100644 index 0000000..9f82a1d --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContract.kt @@ -0,0 +1,67 @@ +package com.tomasrepcik.sensorbox.recording.active + +import com.tomasrepcik.sensorbox.navigation.MainRoute +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingDraft +import com.tomasrepcik.sensorbox.recording.RecordingMessage +import com.tomasrepcik.sensorbox.recording.RecordingScreenEffect +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.toDraft + +sealed interface RecordIntent { + data class Navigate(val route: MainRoute) : RecordIntent + data class ToggleSensor(val sensorId: Int) : RecordIntent + data class ToggleWatchSensor(val sensorId: Int) : RecordIntent + data class OpenSensorDetails(val sensorType: Int?, val device: RecordingDevice = RecordingDevice.PHONE) : + RecordIntent + data object ToggleGps : RecordIntent + data object ToggleWatchGps : RecordIntent + data object OpenRecordingSetup : RecordIntent + data object ClearMessage : RecordIntent +} + +sealed interface RecordEffect : RecordingScreenEffect { + data class Navigate(val route: MainRoute) : RecordEffect + data class OpenSensorDetails(val sensorType: Int?, val device: RecordingDevice) : RecordEffect + data class OpenRecordingSetup(val draft: RecordingDraft) : RecordEffect +} + +data class RecordNext(val state: RecordingState, val effect: RecordEffect? = null) + +object RecordReducer { + fun reduce(state: RecordingState, intent: RecordIntent): RecordNext = when (intent) { + is RecordIntent.Navigate -> RecordNext(state, RecordEffect.Navigate(intent.route)) + + is RecordIntent.OpenSensorDetails -> RecordNext( + state, + RecordEffect.OpenSensorDetails(intent.sensorType, intent.device), + ) + + RecordIntent.OpenRecordingSetup -> RecordNext( + state.copy(message = RecordingMessage.NONE), + RecordEffect.OpenRecordingSetup(state.toDraft()), + ) + + RecordIntent.ClearMessage -> RecordNext(state.copy(message = RecordingMessage.NONE, errorCode = null)) + + RecordIntent.ToggleGps -> RecordNext(state.copy(includesGps = !state.includesGps)) + + RecordIntent.ToggleWatchGps -> RecordNext(state.copy(watchIncludesGps = !state.watchIncludesGps)) + + is RecordIntent.ToggleSensor -> RecordNext(state.toggleSensor(intent.sensorId)) + + is RecordIntent.ToggleWatchSensor -> RecordNext(state.toggleWatchSensor(intent.sensorId)) + } + + private fun RecordingState.toggleSensor(sensorId: Int): RecordingState { + val updated = selectedSensorIds.toMutableSet() + if (!updated.add(sensorId)) updated.remove(sensorId) + return copy(selectedSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) + } + + private fun RecordingState.toggleWatchSensor(sensorId: Int): RecordingState { + val updated = selectedWatchSensorIds.toMutableSet() + if (!updated.add(sensorId)) updated.remove(sensorId) + return copy(selectedWatchSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordRoot.kt new file mode 100644 index 0000000..8939943 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordRoot.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.navigation.RecordingSetupRoute +import com.tomasrepcik.sensorbox.navigation.SensorDetailsRoute + +@Composable +fun RecordRoot(onNavigate: (NavKey) -> Unit, viewModel: RecordViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is RecordEffect.Navigate -> onNavigate(effect.route) + + is RecordEffect.OpenSensorDetails -> onNavigate( + SensorDetailsRoute(effect.sensorType, effect.device), + ) + + is RecordEffect.OpenRecordingSetup -> onNavigate(RecordingSetupRoute(effect.draft)) + } + } + } + FullScreen { modifier -> + RecordScreen(state, viewModel::accept, modifier) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordScreen.kt similarity index 58% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordScreen.kt index 45df5d6..a7cf424 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordScreen.kt @@ -1,13 +1,15 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.setup.SensorSelectionTopBar @Composable -fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { +fun RecordScreen(state: RecordingState, onIntent: (RecordIntent) -> Unit, modifier: Modifier = Modifier) { Column(modifier.fillMaxSize()) { Box(Modifier.weight(1f)) { RecordContent(state, onIntent) } SensorSelectionTopBar(state, onIntent) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordViewModel.kt new file mode 100644 index 0000000..f521048 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordViewModel.kt @@ -0,0 +1,67 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSourcesUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RecordViewModel @Inject constructor( + private val availableSources: AvailableRecordingSourcesUseCase, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(initialState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observeAvailableSources() + } + + fun accept(intent: RecordIntent) { + val next = RecordReducer.reduce(state.value, intent) + mutableState.value = next.state + next.effect?.let(mutableEffects::trySend) + } + + private fun initialState() = RecordingState( + sensors = availableSources.current.phoneSensors, + watchSensors = availableSources.current.watchSensors, + isWatchConnected = availableSources.current.isWatchConnected, + ) + + private fun observeAvailableSources() { + viewModelScope.launch { + availableSources.observe().catch { cause -> + appFailures.show( + AppError( + AppErrorCode.UNKNOWN, + "Observe available recording sources", + "Recording source observation failed", + cause, + ), + ) + }.collect { sources -> + mutableState.value = state.value.copy( + sensors = sources.phoneSensors, + isWatchConnected = sources.isWatchConnected, + watchSensors = sources.watchSensors, + ) + } + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/archive/RecordingArchiveRepository.kt similarity index 89% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/archive/RecordingArchiveRepository.kt index 24c6801..10a54f0 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/archive/RecordingArchiveRepository.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording.archive -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import javax.inject.Inject diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/GpsDetails.kt similarity index 86% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/details/GpsDetails.kt index d8a8804..2ebf370 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/GpsDetails.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -12,13 +12,15 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData +import com.tomasrepcik.sensorbox.design.SensorBoxSecondaryButton +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.preview.GpsPreviewData @Composable -internal fun GpsDetails(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun GpsDetails(state: RecordingState, onIntent: (SensorDetailsIntent) -> Unit) { DisposableEffect(Unit) { - onIntent(RecordingIntent.StartGpsPreview) - onDispose { onIntent(RecordingIntent.StopPreview) } + onIntent(SensorDetailsIntent.StartGpsPreview) + onDispose { onIntent(SensorDetailsIntent.StopPreview) } } val details = state.gpsPreview val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) @@ -27,7 +29,7 @@ internal fun GpsDetails(state: RecordingState, onIntent: (RecordingIntent) -> Un GpsPositionRows(details, unavailableValue) GpsRecordingRows(state) if (!details.hasPermission) { - GpsPermissionButton { onIntent(RecordingIntent.RequestLocationPreviewPermission) } + GpsPermissionButton { onIntent(SensorDetailsIntent.RequestLocationPreviewPermission) } } } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/HardwareSensorDetails.kt similarity index 94% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/details/HardwareSensorDetails.kt index b2649a7..4eb52a0 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/HardwareSensorDetails.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import android.hardware.Sensor import androidx.compose.foundation.layout.Column @@ -8,8 +8,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.SensorReportingMode @Composable internal fun HardwareSensorDetails(sensor: SensorDescriptor) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailComponents.kt similarity index 95% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailComponents.kt index 0020853..c0f0370 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContent.kt similarity index 82% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContent.kt index 3b09b12..561fb89 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContent.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -12,7 +12,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.design.SensorBoxPrimaryButton +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor internal fun selectedDetailsSensor(state: RecordingState): SensorDescriptor? { val sensorType = state.detailsSensorType ?: return null @@ -29,7 +33,7 @@ internal fun SensorDetailsList( sensor: SensorDescriptor?, title: String, showPreview: Boolean, - onIntent: (RecordingIntent) -> Unit, + onIntent: (SensorDetailsIntent) -> Unit, onBack: () -> Unit, ) { SensorBoxBackScreen( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContract.kt new file mode 100644 index 0000000..bf6c760 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsContract.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.recording.details + +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingScreenEffect +import com.tomasrepcik.sensorbox.recording.RecordingState + +sealed interface SensorDetailsIntent { + data class LoadDetails(val sensorType: Int?, val device: RecordingDevice) : SensorDetailsIntent + data object OpenSensorPreview : SensorDetailsIntent + data object StartGpsPreview : SensorDetailsIntent + data object StopPreview : SensorDetailsIntent + data object RequestLocationPreviewPermission : SensorDetailsIntent +} + +sealed interface SensorDetailsEffect : RecordingScreenEffect { + data class OpenSensorPreview(val sensorType: Int?, val device: RecordingDevice) : SensorDetailsEffect + data object RequestLocationPreviewPermission : SensorDetailsEffect +} + +object SensorDetailsReducer { + fun reduce(state: RecordingState, intent: SensorDetailsIntent): RecordingState = when (intent) { + is SensorDetailsIntent.LoadDetails -> state.copy( + detailsSensorType = intent.sensorType, + detailsDevice = intent.device, + ) + + SensorDetailsIntent.OpenSensorPreview, + SensorDetailsIntent.StartGpsPreview, + SensorDetailsIntent.StopPreview, + SensorDetailsIntent.RequestLocationPreviewPermission, + -> state + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsRoot.kt new file mode 100644 index 0000000..4e9cda1 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsRoot.kt @@ -0,0 +1,50 @@ +package com.tomasrepcik.sensorbox.recording.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.navigation.SensorPreviewRoute +import com.tomasrepcik.sensorbox.platform.rememberLocationPreviewPermissionRequest +import com.tomasrepcik.sensorbox.recording.RecordingDevice + +@Composable +fun SensorDetailsRoot( + sensorType: Int?, + device: RecordingDevice, + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, + viewModel: SensorDetailsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val requestLocationPermission = rememberLocationPreviewPermissionRequest( + onResult = viewModel::handleLocationPreviewPermissionResult, + onFailure = viewModel::reportFailure, + ) + LaunchedEffect(viewModel, sensorType, device) { + viewModel.accept(SensorDetailsIntent.LoadDetails(sensorType, device)) + } + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + SensorDetailsEffect.RequestLocationPreviewPermission -> requestLocationPermission() + + is SensorDetailsEffect.OpenSensorPreview -> onNavigate( + SensorPreviewRoute(effect.sensorType, effect.device), + ) + } + } + } + FullScreen { modifier -> + SensorDetailsScreen( + state = state, + onBack = onBack, + onPreview = { viewModel.accept(SensorDetailsIntent.OpenSensorPreview) }, + onIntent = viewModel::accept, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreen.kt similarity index 84% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreen.kt index 6e509db..be2559c 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.details import android.hardware.Sensor import androidx.compose.foundation.layout.Box @@ -8,13 +8,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingState @Composable fun SensorDetailsScreen( state: RecordingState, onBack: () -> Unit, onPreview: () -> Unit, - onIntent: (RecordingIntent) -> Unit, + onIntent: (SensorDetailsIntent) -> Unit, modifier: Modifier = Modifier, ) { val sensor = selectedDetailsSensor(state) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsViewModel.kt new file mode 100644 index 0000000..d2547a2 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/details/SensorDetailsViewModel.kt @@ -0,0 +1,126 @@ +package com.tomasrepcik.sensorbox.recording.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.preview.DevicePreviewRepository +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSourcesUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SensorDetailsViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val availableSources: AvailableRecordingSourcesUseCase, + private val devicePreview: DevicePreviewRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(initialState()) + private val mutableEffects = Channel(Channel.BUFFERED) + private var previewJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observePreferences() + observeAvailableSources() + } + + fun accept(intent: SensorDetailsIntent) { + when (intent) { + SensorDetailsIntent.StartGpsPreview -> startGpsPreview() + + SensorDetailsIntent.StopPreview -> stopPreview() + + SensorDetailsIntent.RequestLocationPreviewPermission -> + mutableEffects.trySend(SensorDetailsEffect.RequestLocationPreviewPermission) + + SensorDetailsIntent.OpenSensorPreview -> mutableEffects.trySend( + SensorDetailsEffect.OpenSensorPreview(state.value.detailsSensorType, state.value.detailsDevice), + ) + + else -> mutableState.value = SensorDetailsReducer.reduce(state.value, intent) + } + } + + fun handleLocationPreviewPermissionResult() { + startGpsPreview() + } + + fun reportFailure(error: AppError) { + appFailures.show(error) + } + + private fun initialState() = RecordingState( + sensors = availableSources.current.phoneSensors, + watchSensors = availableSources.current.watchSensors, + isWatchConnected = availableSources.current.isWatchConnected, + ) + + private fun startGpsPreview() { + previewJob?.cancel() + val preferences = state.value.preferences.recording + previewJob = viewModelScope.launch { + devicePreview.observeGps( + preferences.gpsIntervalSeconds, + preferences.gpsMinDistanceMeters, + ).collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy(gpsPreview = result.value) + is AppResult.Failure -> appFailures.show(result.error) + } + } + } + } + + private fun stopPreview() { + previewJob?.cancel() + previewJob = null + } + + private fun observePreferences() { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + result.fold( + onSuccess = { preferences -> mutableState.value = state.value.copy(preferences = preferences) }, + onFailure = appFailures::show, + ) + } + } + } + + private fun observeAvailableSources() { + viewModelScope.launch { + availableSources.observe().catch { cause -> + appFailures.show( + AppError( + AppErrorCode.UNKNOWN, + "Observe available recording sources", + "Recording source observation failed", + cause, + ), + ) + }.collect { sources -> + mutableState.value = state.value.copy( + sensors = sources.phoneSensors, + watchSensors = sources.watchSensors, + isWatchConnected = sources.isWatchConnected, + ) + } + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/DevicePreviewRepository.kt similarity index 94% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/DevicePreviewRepository.kt index 0587d20..d21fe5e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/DevicePreviewRepository.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.preview +package com.tomasrepcik.sensorbox.recording.preview import android.Manifest import android.content.Context @@ -12,10 +12,10 @@ import android.os.PowerManager import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationAvailability import com.google.android.gms.location.LocationServices -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.sensorservices.handlers.GPSHandler +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/GpsPreview.kt similarity index 76% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/GpsPreview.kt index 9384664..a21ea5c 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/GpsPreview.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.preview import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -11,21 +11,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxSecondaryButton +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.details.formatDecimal @Composable -internal fun GpsPreview(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun GpsPreview(state: RecordingState, onIntent: (SensorPreviewIntent) -> Unit) { DisposableEffect(Unit) { - onIntent(RecordingIntent.StartGpsPreview) - onDispose { onIntent(RecordingIntent.StopPreview) } + onIntent(SensorPreviewIntent.StartGpsPreview) + onDispose { onIntent(SensorPreviewIntent.StopPreview) } } val details = state.gpsPreview val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) - GpsPreviewPanel(details, unavailableValue) { onIntent(RecordingIntent.RequestLocationPreviewPermission) } + GpsPreviewPanel(details, unavailableValue) { + onIntent(SensorPreviewIntent.RequestLocationPreviewPermission) + } } @Composable private fun GpsPreviewPanel( - details: com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData, + details: com.tomasrepcik.sensorbox.recording.preview.GpsPreviewData, unavailableValue: String, onRequestPermission: () -> Unit, ) { @@ -37,7 +42,7 @@ private fun GpsPreviewPanel( @Composable private fun GpsPreviewValues( - details: com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData, + details: com.tomasrepcik.sensorbox.recording.preview.GpsPreviewData, unavailableValue: String, ) { PreviewValueRow( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/HardwareSensorPreview.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/HardwareSensorPreview.kt index 8657b56..f9c2a36 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/HardwareSensorPreview.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.preview import android.hardware.Sensor import androidx.compose.foundation.layout.Arrangement @@ -13,9 +13,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData -import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewSample -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.details.formatDecimal +import com.tomasrepcik.sensorbox.recording.details.sensorUnit +import com.tomasrepcik.sensorbox.recording.preview.SensorPreviewData +import com.tomasrepcik.sensorbox.recording.preview.SensorPreviewSample +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor @Composable internal fun HardwareSensorPreview(sensor: SensorDescriptor, preview: SensorPreviewData) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/LiveSensorChart.kt similarity index 97% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/LiveSensorChart.kt index 974a523..98716d4 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/LiveSensorChart.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.preview import android.graphics.Paint import androidx.compose.foundation.Canvas @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.graphics.withRotation import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats import kotlin.math.abs @@ -168,10 +169,9 @@ private fun DrawScope.drawAxisTitles(plot: ChartPlot, labels: LiveChartLabels, p nativeCanvas.drawText(labels.timeAxis, plot.left + plot.width / 2f, size.height - 2.dp.toPx(), paint) val centerY = plot.top + plot.height / 2f val titleX = 10.dp.toPx() - nativeCanvas.save() - nativeCanvas.rotate(-90f, titleX, centerY) - nativeCanvas.drawText(labels.valueAxis, titleX, centerY, paint) - nativeCanvas.restore() + nativeCanvas.withRotation(-90f, titleX, centerY) { + drawText(labels.valueAxis, titleX, centerY, paint) + } } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewComponents.kt similarity index 89% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewComponents.kt index f43355b..162a321 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.preview import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.recording.details.DetailRow @Composable internal fun PreviewValueRow(label: String, value: String, showDivider: Boolean = true) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewContract.kt new file mode 100644 index 0000000..262cd3c --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewContract.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.recording.preview + +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingScreenEffect +import com.tomasrepcik.sensorbox.recording.RecordingState + +sealed interface SensorPreviewIntent { + data class LoadDetails(val sensorType: Int?, val device: RecordingDevice) : SensorPreviewIntent + data object StartGpsPreview : SensorPreviewIntent + data class StartSensorPreview(val sensorType: Int) : SensorPreviewIntent + data object StopPreview : SensorPreviewIntent + data object RequestLocationPreviewPermission : SensorPreviewIntent +} + +sealed interface SensorPreviewEffect : RecordingScreenEffect { + data object RequestLocationPreviewPermission : SensorPreviewEffect +} + +object SensorPreviewReducer { + fun reduce(state: RecordingState, intent: SensorPreviewIntent): RecordingState = when (intent) { + is SensorPreviewIntent.LoadDetails -> state.copy( + detailsSensorType = intent.sensorType, + detailsDevice = intent.device, + ) + + SensorPreviewIntent.StartGpsPreview, + is SensorPreviewIntent.StartSensorPreview, + SensorPreviewIntent.StopPreview, + SensorPreviewIntent.RequestLocationPreviewPermission, + -> state + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewRoot.kt new file mode 100644 index 0000000..deeaa91 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewRoot.kt @@ -0,0 +1,37 @@ +package com.tomasrepcik.sensorbox.recording.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.platform.rememberLocationPreviewPermissionRequest +import com.tomasrepcik.sensorbox.recording.RecordingDevice + +@Composable +fun SensorPreviewRoot( + sensorType: Int?, + device: RecordingDevice, + onBack: () -> Unit, + viewModel: SensorPreviewViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val requestLocationPermission = rememberLocationPreviewPermissionRequest( + onResult = viewModel::handleLocationPreviewPermissionResult, + onFailure = viewModel::reportFailure, + ) + LaunchedEffect(viewModel, sensorType, device) { + viewModel.accept(SensorPreviewIntent.LoadDetails(sensorType, device)) + } + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + SensorPreviewEffect.RequestLocationPreviewPermission -> requestLocationPermission() + } + } + } + FullScreen { modifier -> + SensorPreviewScreen(state, viewModel::accept, onBack, modifier) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewScreen.kt similarity index 74% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewScreen.kt index 39b2766..f71f312 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewScreen.kt @@ -1,15 +1,18 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.preview import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingState @Composable fun SensorPreviewScreen( state: RecordingState, - onIntent: (RecordingIntent) -> Unit, + onIntent: (SensorPreviewIntent) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -33,8 +36,8 @@ fun SensorPreviewScreen( } else if (sensor != null) { item { DisposableEffect(sensor.type) { - onIntent(RecordingIntent.StartSensorPreview(sensor.type)) - onDispose { onIntent(RecordingIntent.StopPreview) } + onIntent(SensorPreviewIntent.StartSensorPreview(sensor.type)) + onDispose { onIntent(SensorPreviewIntent.StopPreview) } } HardwareSensorPreview(sensor, state.sensorPreview) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewViewModel.kt new file mode 100644 index 0000000..54fec20 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/preview/SensorPreviewViewModel.kt @@ -0,0 +1,135 @@ +package com.tomasrepcik.sensorbox.recording.preview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSourcesUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SensorPreviewViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val availableSources: AvailableRecordingSourcesUseCase, + private val devicePreview: DevicePreviewRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(initialState()) + private val mutableEffects = Channel(Channel.BUFFERED) + private var previewJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observePreferences() + observeAvailableSources() + } + + fun accept(intent: SensorPreviewIntent) { + when (intent) { + SensorPreviewIntent.StartGpsPreview -> startGpsPreview() + + is SensorPreviewIntent.StartSensorPreview -> startSensorPreview(intent.sensorType) + + SensorPreviewIntent.StopPreview -> stopPreview() + + SensorPreviewIntent.RequestLocationPreviewPermission -> + mutableEffects.trySend(SensorPreviewEffect.RequestLocationPreviewPermission) + + else -> mutableState.value = SensorPreviewReducer.reduce(state.value, intent) + } + } + + fun handleLocationPreviewPermissionResult() { + startGpsPreview() + } + + fun reportFailure(error: AppError) { + appFailures.show(error) + } + + private fun initialState() = RecordingState( + sensors = availableSources.current.phoneSensors, + watchSensors = availableSources.current.watchSensors, + isWatchConnected = availableSources.current.isWatchConnected, + ) + + private fun startGpsPreview() { + previewJob?.cancel() + val preferences = state.value.preferences.recording + previewJob = viewModelScope.launch { + devicePreview.observeGps( + preferences.gpsIntervalSeconds, + preferences.gpsMinDistanceMeters, + ).collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy(gpsPreview = result.value) + is AppResult.Failure -> appFailures.show(result.error) + } + } + } + } + + private fun startSensorPreview(sensorType: Int) { + previewJob?.cancel() + previewJob = viewModelScope.launch { + devicePreview.observeSensor(sensorType).collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy(sensorPreview = result.value) + is AppResult.Failure -> appFailures.show(result.error) + } + } + } + } + + private fun stopPreview() { + previewJob?.cancel() + previewJob = null + } + + private fun observePreferences() { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + result.fold( + onSuccess = { preferences -> mutableState.value = state.value.copy(preferences = preferences) }, + onFailure = appFailures::show, + ) + } + } + } + + private fun observeAvailableSources() { + viewModelScope.launch { + availableSources.observe().catch { cause -> + appFailures.show( + AppError( + AppErrorCode.UNKNOWN, + "Observe available recording sources", + "Recording source observation failed", + cause, + ), + ) + }.collect { sources -> + mutableState.value = state.value.copy( + sensors = sources.phoneSensors, + watchSensors = sources.watchSensors, + isWatchConnected = sources.isWatchConnected, + ) + } + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/GpsSettings.kt similarity index 82% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/GpsSettings.kt index acca214..46bc888 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/GpsSettings.kt @@ -1,10 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.NumberPickerSetting +import com.tomasrepcik.sensorbox.settings.SettingsIntent +import com.tomasrepcik.sensorbox.settings.SettingsState @Composable internal fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/PowerSettings.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/PowerSettings.kt index 33f3345..b5eaf5f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/PowerSettings.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,6 +11,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxSecondaryButton +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.settings.BooleanSetting +import com.tomasrepcik.sensorbox.settings.SettingsIntent +import com.tomasrepcik.sensorbox.settings.SettingsState @Composable internal fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetup.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetup.kt similarity index 92% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetup.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetup.kt index 74abde8..926456f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetup.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetup.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording.setup import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest data class RecordingSetup( val sensorIds: Set, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupComponents.kt similarity index 78% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupComponents.kt index 7791c8d..d3a0a5e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,12 +17,17 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxSecondaryButton +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.settings.BooleanSetting +import com.tomasrepcik.sensorbox.settings.StepSetting @Composable -internal fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { OutlinedTextField( value = state.customMeasurementName, - onValueChange = { onIntent(RecordingIntent.SetCustomMeasurementName(it)) }, + onValueChange = { onIntent(RecordingSetupIntent.SetCustomMeasurementName(it)) }, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), label = { Text(stringResource(R.string.custom_measurement_name)) }, supportingText = { Text(stringResource(R.string.custom_measurement_name_description)) }, @@ -32,7 +37,7 @@ internal fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingInt } @Composable -internal fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { Column { StepSetting( stringResource(R.string.start_delay), @@ -40,22 +45,22 @@ internal fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingInt pluralStringResource(R.plurals.seconds_count, state.startDelaySeconds, state.startDelaySeconds), 0, 86_400, - ) { onIntent(RecordingIntent.SetStartDelay(it)) } + ) { onIntent(RecordingSetupIntent.SetStartDelay(it)) } StepSetting( stringResource(R.string.recording_duration), state.durationSeconds, pluralStringResource(R.plurals.seconds_count, state.durationSeconds, state.durationSeconds), 0, 86_400, - ) { onIntent(RecordingIntent.SetDuration(it)) } + ) { onIntent(RecordingSetupIntent.SetDuration(it)) } } } @Composable -internal fun NotesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun NotesSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { OutlinedTextField( value = state.notes, - onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, + onValueChange = { onIntent(RecordingSetupIntent.SetNotes(it)) }, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), label = { Text(stringResource(R.string.recording_notes)) }, supportingText = { Text(stringResource(R.string.recording_notes_description)) }, @@ -65,10 +70,10 @@ internal fun NotesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Un } @Composable -internal fun AlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun AlarmsSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { OutlinedTextField( value = state.alarmOffsets, - onValueChange = { onIntent(RecordingIntent.SetAlarmOffsets(it)) }, + onValueChange = { onIntent(RecordingSetupIntent.SetAlarmOffsets(it)) }, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), label = { Text(stringResource(R.string.audible_alarm_offsets)) }, supportingText = { Text(stringResource(R.string.audible_alarm_offsets_description)) }, @@ -78,12 +83,12 @@ internal fun AlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> U } @Composable -internal fun ActivityRecognitionSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun ActivityRecognitionSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.activity_recognition), description = stringResource(R.string.activity_recognition_description), checked = state.activityRecognition, - ) { onIntent(RecordingIntent.SetActivityRecognition(it)) } + ) { onIntent(RecordingSetupIntent.SetActivityRecognition(it)) } if (state.activityRecognition) { StepSetting( stringResource(R.string.activity_recognition_period), @@ -95,17 +100,17 @@ internal fun ActivityRecognitionSetup(state: RecordingState, onIntent: (Recordin ), 1, 3_600, - ) { onIntent(RecordingIntent.SetActivityRecognitionPeriod(it)) } + ) { onIntent(RecordingSetupIntent.SetActivityRecognitionPeriod(it)) } } } @Composable -internal fun SignificantMotionSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun SignificantMotionSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.significant_motion), description = stringResource(R.string.significant_motion_description), checked = state.significantMotion, - ) { onIntent(RecordingIntent.SetSignificantMotionRecording(it)) } + ) { onIntent(RecordingSetupIntent.SetSignificantMotionRecording(it)) } } @Composable @@ -128,34 +133,34 @@ internal fun RecordingArchivePanel(path: String?, onChoose: () -> Unit) { } @Composable -internal fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun BatterySetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_setup_description), checked = state.preferences.recording.stopRecordingOnLowBattery, - ) { onIntent(RecordingIntent.SetStopOnLowBattery(it)) } + ) { onIntent(RecordingSetupIntent.SetStopOnLowBattery(it)) } } @Composable -internal fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun WakeLockSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_setup_description), checked = state.preferences.recording.useWakeLock, - ) { onIntent(RecordingIntent.SetWakeLock(it)) } + ) { onIntent(RecordingSetupIntent.SetWakeLock(it)) } } @Composable -internal fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_setup_description), checked = state.preferences.display.keepPhoneDisplayOn, - ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } + ) { onIntent(RecordingSetupIntent.SetKeepScreenAwake(it)) } } @Composable -internal fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { val seconds = state.preferences.recording.gpsIntervalSeconds StepSetting( stringResource(R.string.gps_interval), @@ -163,11 +168,11 @@ internal fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) pluralStringResource(R.plurals.seconds_count, seconds, seconds), 1, 3_600, - ) { onIntent(RecordingIntent.SetGpsInterval(it)) } + ) { onIntent(RecordingSetupIntent.SetGpsInterval(it)) } } @Composable -internal fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +internal fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { val meters = state.preferences.recording.gpsMinDistanceMeters StepSetting( stringResource(R.string.gps_minimum_distance), @@ -175,5 +180,5 @@ internal fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) pluralStringResource(R.plurals.meters_count, meters, meters), 0, 10_000, - ) { onIntent(RecordingIntent.SetGpsDistance(it)) } + ) { onIntent(RecordingSetupIntent.SetGpsDistance(it)) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContent.kt similarity index 67% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContent.kt index ce16a3d..a42390d 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContent.kt @@ -1,13 +1,22 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsSection +import com.tomasrepcik.sensorbox.recording.RecordingMessageText +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.settings.SamplingSetting @Composable -internal fun RecordingSetupContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit, onBack: () -> Unit) { +internal fun RecordingSetupContent( + state: RecordingState, + onIntent: (RecordingSetupIntent) -> Unit, + onBack: () -> Unit, +) { SensorBoxBackScreen( title = stringResource(R.string.recording_setup), onBack = onBack, @@ -24,42 +33,49 @@ internal fun RecordingSetupContent(state: RecordingState, onIntent: (RecordingIn } } -private fun LazyListScope.storageSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.storageSetupItems(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { item { SensorBoxSettingsSection(stringResource(R.string.setup_storage_category)) } - item { RecordingArchivePanel(state.recordingArchivePath) { onIntent(RecordingIntent.ChooseRecordingArchive) } } + item { + RecordingArchivePanel(state.recordingArchivePath) { + onIntent(RecordingSetupIntent.ChooseRecordingArchive) + } + } } -private fun LazyListScope.measurementDetailsSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.measurementDetailsSetupItems( + state: RecordingState, + onIntent: (RecordingSetupIntent) -> Unit, +) { item { SensorBoxSettingsSection(stringResource(R.string.setup_details_category)) } item { MeasurementNameSetup(state, onIntent) } item { NotesSetup(state, onIntent) } item { AlarmsSetup(state, onIntent) } } -private fun LazyListScope.timingSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.timingSetupItems(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { item { SensorBoxSettingsSection(stringResource(R.string.setup_timing_category)) } item { RecordingTimingSetup(state, onIntent) } } -private fun LazyListScope.sourceSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.sourceSetupItems(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { item { SensorBoxSettingsSection(stringResource(R.string.setup_sources_category)) } item { SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> - onIntent(RecordingIntent.SetSamplingPeriod(index)) + onIntent(RecordingSetupIntent.SetSamplingPeriod(index)) } } item { ActivityRecognitionSetup(state, onIntent) } item { SignificantMotionSetup(state, onIntent) } } -private fun LazyListScope.recordingOptionSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.recordingOptionSetupItems(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { item { SensorBoxSettingsSection(stringResource(R.string.settings_recording_category)) } item { BatterySetup(state, onIntent) } item { WakeLockSetup(state, onIntent) } item { KeepScreenAwakeSetup(state, onIntent) } } -private fun LazyListScope.locationSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { +private fun LazyListScope.locationSetupItems(state: RecordingState, onIntent: (RecordingSetupIntent) -> Unit) { item { SensorBoxSettingsSection(stringResource(R.string.settings_location_category)) } item { GpsIntervalSetup(state, onIntent) } item { GpsDistanceSetup(state, onIntent) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContract.kt new file mode 100644 index 0000000..b4800e6 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupContract.kt @@ -0,0 +1,85 @@ +package com.tomasrepcik.sensorbox.recording.setup + +import com.tomasrepcik.sensorbox.recording.RecordingDraft +import com.tomasrepcik.sensorbox.recording.RecordingMessage +import com.tomasrepcik.sensorbox.recording.RecordingScreenEffect +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.withDraft + +sealed interface RecordingSetupIntent { + data class LoadDraft(val draft: RecordingDraft) : RecordingSetupIntent + data object ChooseRecordingArchive : RecordingSetupIntent + data object StartRecording : RecordingSetupIntent + data object ClearMessage : RecordingSetupIntent + data class SetCustomMeasurementName(val value: String) : RecordingSetupIntent + data class SetStartDelay(val seconds: Int) : RecordingSetupIntent + data class SetDuration(val seconds: Int) : RecordingSetupIntent + data class SetNotes(val value: String) : RecordingSetupIntent + data class SetAlarmOffsets(val value: String) : RecordingSetupIntent + data class SetActivityRecognition(val enabled: Boolean) : RecordingSetupIntent + data class SetActivityRecognitionPeriod(val seconds: Int) : RecordingSetupIntent + data class SetSignificantMotionRecording(val enabled: Boolean) : RecordingSetupIntent + data class SetSamplingPeriod(val index: Int) : RecordingSetupIntent + data class SetStopOnLowBattery(val enabled: Boolean) : RecordingSetupIntent + data class SetWakeLock(val enabled: Boolean) : RecordingSetupIntent + data class SetKeepScreenAwake(val enabled: Boolean) : RecordingSetupIntent + data class SetGpsInterval(val seconds: Int) : RecordingSetupIntent + data class SetGpsDistance(val meters: Int) : RecordingSetupIntent +} + +sealed interface RecordingSetupEffect : RecordingScreenEffect { + data object PickRecordingArchive : RecordingSetupEffect + data class RequestPermissions(val permissions: Set) : RecordingSetupEffect +} + +object RecordingSetupReducer { + fun reduce(state: RecordingState, intent: RecordingSetupIntent): RecordingState = when (intent) { + is RecordingSetupIntent.LoadDraft -> state.withDraft(intent.draft) + + RecordingSetupIntent.ClearMessage -> state.copy(message = RecordingMessage.NONE, errorCode = null) + + is RecordingSetupIntent.SetCustomMeasurementName -> state.copy(customMeasurementName = intent.value) + + is RecordingSetupIntent.SetStartDelay -> state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0)) + + is RecordingSetupIntent.SetDuration -> state.copy(durationSeconds = intent.seconds.coerceAtLeast(0)) + + is RecordingSetupIntent.SetNotes -> state.copy(notes = intent.value) + + is RecordingSetupIntent.SetAlarmOffsets -> state.copy(alarmOffsets = intent.value) + + is RecordingSetupIntent.SetActivityRecognition -> state.copy(activityRecognition = intent.enabled) + + is RecordingSetupIntent.SetActivityRecognitionPeriod -> state.copy( + activityRecognitionPeriodSeconds = intent.seconds.coerceAtLeast(1), + ) + + is RecordingSetupIntent.SetSignificantMotionRecording -> state.copy(significantMotion = intent.enabled) + + RecordingSetupIntent.ChooseRecordingArchive, + RecordingSetupIntent.StartRecording, + is RecordingSetupIntent.SetSamplingPeriod, + is RecordingSetupIntent.SetStopOnLowBattery, + is RecordingSetupIntent.SetWakeLock, + is RecordingSetupIntent.SetKeepScreenAwake, + is RecordingSetupIntent.SetGpsInterval, + is RecordingSetupIntent.SetGpsDistance, + -> state + } + + fun recordingStartRequested(state: RecordingState, countdownSeconds: Int): RecordingState = state.copy( + isStarting = true, + startCountdownSeconds = countdownSeconds.takeIf { it > 0 }, + message = RecordingMessage.NONE, + errorCode = null, + ) + + fun recordingCountdownChanged(state: RecordingState, seconds: Int?): RecordingState = state.copy( + startCountdownSeconds = seconds, + ) + + fun recordingStartFailed(state: RecordingState): RecordingState = state.copy( + isStarting = false, + startCountdownSeconds = null, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupRoot.kt new file mode 100644 index 0000000..62cdeef --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupRoot.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.recording.setup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.platform.rememberPermissionRequest +import com.tomasrepcik.sensorbox.platform.rememberRecordingArchivePicker +import com.tomasrepcik.sensorbox.recording.RecordingDraft + +@Composable +fun RecordingSetupRoot( + draft: RecordingDraft, + onBack: () -> Unit, + viewModel: RecordingSetupViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val pickRecordingArchive = rememberRecordingArchivePicker( + onResult = viewModel::handleRecordingArchiveResult, + onFailure = viewModel::reportFailure, + ) + val requestPermissions = rememberPermissionRequest( + onResult = viewModel::handlePermissionResult, + onFailure = viewModel::reportFailure, + ) + LaunchedEffect(viewModel, draft) { + viewModel.accept(RecordingSetupIntent.LoadDraft(draft)) + } + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + RecordingSetupEffect.PickRecordingArchive -> pickRecordingArchive() + is RecordingSetupEffect.RequestPermissions -> requestPermissions(effect.permissions) + } + } + } + FullScreen { modifier -> + RecordingSetupScreen( + state = state, + onIntent = viewModel::accept, + modifier = modifier, + onBack = onBack, + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreen.kt similarity index 74% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreen.kt index 1b95c68..d9c55c0 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupScreen.kt @@ -1,17 +1,18 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tomasrepcik.sensorbox.recording.RecordingState @Composable fun RecordingSetupScreen( state: RecordingState, - onIntent: (RecordingIntent) -> Unit, + onIntent: (RecordingSetupIntent) -> Unit, modifier: Modifier = Modifier, - onBack: () -> Unit = { onIntent(RecordingIntent.ReturnToSensorSelection) }, + onBack: () -> Unit, ) { Column(modifier.fillMaxSize()) { Box(Modifier.weight(1f)) { RecordingSetupContent(state, onIntent, onBack) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTopBar.kt similarity index 86% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTopBar.kt index 8d1f537..33cca33 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTopBar.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -12,11 +12,13 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxPrimaryButton +import com.tomasrepcik.sensorbox.recording.RecordingState @Composable internal fun RecordingSetupTopBar( state: RecordingState, - onIntent: (RecordingIntent) -> Unit, + onIntent: (RecordingSetupIntent) -> Unit, modifier: Modifier = Modifier, ) { val sourceCount = setupSourceCount(state) @@ -33,7 +35,7 @@ internal fun RecordingSetupTopBar( 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_recording), - onClick = { onIntent(RecordingIntent.StartRecording) }, + onClick = { onIntent(RecordingSetupIntent.StartRecording) }, modifier = Modifier.fillMaxWidth(), enabled = state.recordingArchivePath != null && sourceCount > 0 && !state.isStarting, loading = state.isStarting, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupViewModel.kt new file mode 100644 index 0000000..f0faf4d --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupViewModel.kt @@ -0,0 +1,227 @@ +package com.tomasrepcik.sensorbox.recording.setup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent +import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository +import com.tomasrepcik.sensorbox.recording.RecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.RecordingMessage +import com.tomasrepcik.sensorbox.recording.RecordingPermissionsUseCase +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.active.RecordingSessionStateReducer +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveSelection +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RecordingSetupViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val recordingArchive: RecordingArchiveRepository, + private val permissions: RecordingPermissionsUseCase, + private val recording: RecordingControlUseCase, + private val sessionStore: RecordingSessionStore, + private val appFailures: AppFailureStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(RecordingState(recordingArchivePath = readRecordingArchivePath())) + private val mutableEffects = Channel(Channel.BUFFERED) + private var startJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observePreferences() + observeRecordingSession() + observeRecordingStops() + } + + fun accept(intent: RecordingSetupIntent) { + intent.toPreferencesIntent()?.let { + updatePreference(it) + return + } + when (intent) { + RecordingSetupIntent.StartRecording -> startRecording() + + RecordingSetupIntent.ChooseRecordingArchive -> + mutableEffects.trySend(RecordingSetupEffect.PickRecordingArchive) + + else -> mutableState.value = RecordingSetupReducer.reduce(state.value, intent) + } + } + + fun handleRecordingArchiveResult(selection: RecordingArchiveSelection) { + if (selection is RecordingArchiveSelection.Cancelled) return + + val persisted = recordingArchive.select(selection as RecordingArchiveSelection.Selected) + persisted.errorOrNull()?.let(appFailures::show) + mutableState.value = state.value.copy( + recordingArchivePath = readRecordingArchivePath(), + message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.RECORDING_ARCHIVE_REQUIRED, + errorCode = persisted.errorOrNull()?.code, + ) + } + + fun handlePermissionResult() { + val request = state.value.toRecordingSetup() + if (permissions.missingPermissions(request).isEmpty()) { + startRecording() + } else { + showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + } + } + + fun refreshRecordingArchive() { + mutableState.value = state.value.copy(recordingArchivePath = readRecordingArchivePath()) + } + + fun reportFailure(error: AppError) { + appFailures.show(error) + } + + private fun RecordingSetupIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { + is RecordingSetupIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) + is RecordingSetupIntent.SetStopOnLowBattery -> AppPreferencesIntent.SetStopOnLowBattery(enabled) + is RecordingSetupIntent.SetWakeLock -> AppPreferencesIntent.SetWakeLock(enabled) + is RecordingSetupIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) + is RecordingSetupIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) + is RecordingSetupIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) + else -> null + } + + private fun observePreferences() { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + result.fold( + onSuccess = { preferences -> + mutableState.value = state.value.copy(preferences = preferences, errorCode = null) + }, + onFailure = appFailures::show, + ) + } + } + } + + private fun observeRecordingSession() { + viewModelScope.launch { + sessionStore.state.collect { session -> + mutableState.value = RecordingSessionStateReducer.sessionChanged(state.value, session) + } + } + } + + private fun observeRecordingStops() { + viewModelScope.launch { + sessionStore.events.collect { event -> + val currentState = state.value + if (currentState.isStarting && currentState.session is RecordingSessionState.Idle) { + mutableState.value = RecordingSetupReducer.recordingStartFailed(currentState) + } + event.result.errorOrNull()?.let(appFailures::show) + } + } + } + + @Suppress("ReturnCount") + private fun startRecording() { + if (state.value.isStarting) return + val request = state.value.toRecordingSetup() + if (!request.hasAnySource()) { + showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) + return + } + when (val selected = recordingArchive.isSelected()) { + is AppResult.Failure -> { + appFailures.show(selected.error) + return + } + + is AppResult.Success -> if (!selected.value) { + mutableEffects.trySend(RecordingSetupEffect.PickRecordingArchive) + showMessage(RecordingMessage.RECORDING_ARCHIVE_REQUIRED, AppErrorCode.STORAGE) + return + } + } + requestMissingPermissions(request)?.let { + mutableEffects.trySend(RecordingSetupEffect.RequestPermissions(it)) + mutableState.value = state.value.copy(errorCode = AppErrorCode.PERMISSION) + return + } + startRecordingAfterDelay(request, state.value.startDelaySeconds) + } + + private fun requestMissingPermissions(request: RecordingSetup): Set? = + permissions.missingPermissions(request).takeIf(Set::isNotEmpty) + + private fun readRecordingArchivePath(): String? = recordingArchive.path().fold( + onSuccess = { it }, + onFailure = { + appFailures.show(it) + null + }, + ) + + private fun startRecordingAfterDelay(request: RecordingSetup, countdownSeconds: Int) { + mutableState.value = RecordingSetupReducer.recordingStartRequested(state.value, countdownSeconds) + startJob = viewModelScope.launch { + waitForStartCountdown(countdownSeconds) + val result = recording.start(request) + if (result.isFailure) { + mutableState.value = RecordingSetupReducer.recordingStartFailed(state.value) + showFailure(result.errorOrNull()) + } + } + } + + private suspend fun waitForStartCountdown(seconds: Int) { + for (remaining in seconds downTo 1) { + mutableState.value = RecordingSetupReducer.recordingCountdownChanged(state.value, remaining) + delay(1_000L) + } + mutableState.value = RecordingSetupReducer.recordingCountdownChanged(state.value, null) + } + + private fun updatePreference(intent: AppPreferencesIntent) { + viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } + } + + private fun showMessage(message: RecordingMessage, errorCode: AppErrorCode? = null) { + mutableState.value = state.value.copy(message = message, errorCode = errorCode) + } + + private fun AppResult<*>.showFailure() { + if (isFailure) showFailure(errorOrNull()) + } + + private fun showFailure(error: AppError?) { + when { + error == null -> appFailures.show(AppError(AppErrorCode.UNKNOWN, "Run recording operation")) + + error.code == AppErrorCode.PERMISSION && error.context["source"] == "watch" -> + showMessage(RecordingMessage.WATCH_PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + + error.code == AppErrorCode.PERMISSION -> + showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + + else -> appFailures.show(error) + } + } + + private fun RecordingSetup.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || + watchSensorIds.isNotEmpty() || watchIncludesGps || activityRecognition || significantMotion +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSourceComponents.kt similarity index 94% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSourceComponents.kt index 32080aa..0f5ede4 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSourceComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.annotation.DrawableRes import androidx.compose.foundation.clickable @@ -23,7 +23,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.design.SensorBoxTopAppBar +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.sensorIconResource @Composable internal fun SensorSourceItem( diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/SensorSelectionTopBar.kt similarity index 81% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/SensorSelectionTopBar.kt index eeacc33..8942159 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/SensorSelectionTopBar.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -11,11 +11,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxPrimaryButton +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.active.RecordIntent @Composable internal fun SensorSelectionTopBar( state: RecordingState, - onIntent: (RecordingIntent) -> Unit, + onIntent: (RecordIntent) -> Unit, modifier: Modifier = Modifier, ) { val sensorCount = selectedSourceCount(state) @@ -30,7 +33,7 @@ internal fun SensorSelectionTopBar( ) { SensorBoxPrimaryButton( label = stringResource(R.string.continue_action), - onClick = { onIntent(RecordingIntent.OpenRecordingSetup) }, + onClick = { onIntent(RecordIntent.OpenRecordingSetup) }, modifier = Modifier.fillMaxWidth(), enabled = sensorCount > 0, ) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetAvailableSensorsUseCase.kt similarity index 97% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetAvailableSensorsUseCase.kt index 0b9fff2..363d0e2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetAvailableSensorsUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetAvailableSensorsUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.content.Context import android.hardware.Sensor diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailability.kt similarity index 77% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailability.kt index 6911ae8..91f3713 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailability.kt @@ -1,14 +1,14 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -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.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorDescriptor.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorDescriptor.kt index 0c0c33c..72d3a66 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/SensorDescriptor.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorDescriptor.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo data class SensorDescriptor( val type: Int, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorIconResource.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorIconResource.kt similarity index 96% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorIconResource.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorIconResource.kt index a3f1c03..8b9be60 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorIconResource.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/sources/SensorIconResource.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording.sources import android.hardware.Sensor import androidx.annotation.DrawableRes diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/AboutSetting.kt similarity index 88% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/AboutSetting.kt index ae1cc78..168ed60 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/AboutSetting.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -7,6 +7,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.about.AboutDialog +import com.tomasrepcik.sensorbox.navigation.MainRoute @Composable internal fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/DiagnosticsSetting.kt similarity index 96% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/DiagnosticsSetting.kt index b18cc19..0c4a20c 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/DiagnosticsSetting.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.navigation.MainRoute @Composable internal fun DiagnosticsSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt similarity index 95% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt index 1b3ee8b..36117b2 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.settings import android.content.Context import androidx.datastore.core.DataStore diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsComponents.kt similarity index 98% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsComponents.kt index f87c435..47c84af 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,6 +26,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsDivider @Composable fun SamplingSetting(selected: Int, onSamplingPeriod: (Int) -> Unit) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsContract.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsContract.kt index ebb5612..cff481e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsContract.kt @@ -1,8 +1,9 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings +import com.tomasrepcik.sensorbox.about.OpenSourceLicense import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense +import com.tomasrepcik.sensorbox.navigation.MainRoute data class SettingsState( val preferences: AppPreferences = AppPreferences(), diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsRoot.kt new file mode 100644 index 0000000..20b4245 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsRoot.kt @@ -0,0 +1,104 @@ +package com.tomasrepcik.sensorbox.settings + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.tomasrepcik.sensorbox.about.OpenSourceLicensesScreen +import com.tomasrepcik.sensorbox.about.PrivacyScreen +import com.tomasrepcik.sensorbox.diagnostics.DiagnosticsLogScreen +import com.tomasrepcik.sensorbox.navigation.FullScreen +import com.tomasrepcik.sensorbox.navigation.MainRoute +import com.tomasrepcik.sensorbox.platform.copyDiagnostics +import com.tomasrepcik.sensorbox.platform.requestBatteryOptimizationExemption +import com.tomasrepcik.sensorbox.platform.shareDiagnosticsFile +import com.tomasrepcik.sensorbox.platform.shareDiagnosticsText +import com.tomasrepcik.sensorbox.platform.showDiagnosticsCleared + +@Composable +fun SettingsRoot( + route: MainRoute, + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + if (route == MainRoute.SETTINGS) { + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.accept(SettingsIntent.RefreshBatteryOptimization) + } + } + LoadSettingsRoute(route, viewModel) + ObserveSettingsEffects(viewModel, context, onNavigate) + FullScreen { modifier -> + RenderSettingsScreen(route, state, viewModel::accept, onBack, modifier) + } +} + +@Composable +private fun LoadSettingsRoute(route: MainRoute, viewModel: SettingsViewModel) { + LaunchedEffect(viewModel, route) { + if (route == MainRoute.SETTINGS || route == MainRoute.DIAGNOSTICS) { + viewModel.accept(SettingsIntent.ViewDiagnostics) + } + } +} + +@Composable +private fun ObserveSettingsEffects(viewModel: SettingsViewModel, context: Context, onNavigate: (NavKey) -> Unit) { + LaunchedEffect(viewModel, context) { + viewModel.effects.collect { effect -> + when (effect) { + SettingsEffect.RequestBatteryOptimizationExemption -> { + requestBatteryOptimizationExemption(context).onFailure(viewModel::reportFailure) + } + + is SettingsEffect.ShareDiagnosticsText -> { + shareDiagnosticsText(context, effect.text).onFailure(viewModel::reportFailure) + } + + is SettingsEffect.ShareDiagnosticsFile -> { + shareDiagnosticsFile( + context, + effect.contentUri, + effect.displayName, + effect.mimeType, + ).onFailure(viewModel::reportFailure) + } + + is SettingsEffect.CopyDiagnosticsText -> { + copyDiagnostics(context, effect.text).onFailure(viewModel::reportFailure) + } + + SettingsEffect.DiagnosticsCleared -> showDiagnosticsCleared(context) + + is SettingsEffect.Navigate -> onNavigate(effect.route) + } + } + } +} + +@Composable +private fun RenderSettingsScreen( + route: MainRoute, + state: SettingsState, + onIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier, +) { + when (route) { + MainRoute.SETTINGS -> SettingsScreen(state, onIntent, modifier, onBack) + MainRoute.DIAGNOSTICS -> DiagnosticsLogScreen(state, onBack, modifier) + MainRoute.LICENSES -> OpenSourceLicensesScreen(state, onIntent, onBack, modifier) + MainRoute.PRIVACY -> PrivacyScreen(onBack, modifier) + else -> error("Not a settings route") + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsScreen.kt similarity index 83% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsScreen.kt index b909cf6..b8f411e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsScreen.kt @@ -1,14 +1,19 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.design.SensorBoxBackScreen +import com.tomasrepcik.sensorbox.design.SensorBoxSettingsSection +import com.tomasrepcik.sensorbox.navigation.MainRoute +import com.tomasrepcik.sensorbox.recording.setup.BatteryGuardSetting +import com.tomasrepcik.sensorbox.recording.setup.BatteryOptimizationSetting +import com.tomasrepcik.sensorbox.recording.setup.CpuWakeLockSetting +import com.tomasrepcik.sensorbox.recording.setup.GpsSettings +import com.tomasrepcik.sensorbox.recording.setup.ScreenAwakeSetting @Composable fun SettingsScreen( @@ -17,8 +22,6 @@ fun SettingsScreen( modifier: Modifier = Modifier, onBack: () -> Unit = { onIntent(SettingsIntent.Navigate(MainRoute.RECORD)) }, ) { - LaunchedEffect(Unit) { onIntent(SettingsIntent.ViewDiagnostics) } - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { onIntent(SettingsIntent.RefreshBatteryOptimization) } SensorBoxBackScreen( title = stringResource(R.string.recording_settings), onBack = onBack, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsViewModel.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsViewModel.kt index af78b03..8ed200f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/SettingsViewModel.kt @@ -1,17 +1,17 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore +import com.tomasrepcik.sensorbox.about.OpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.bootstrap.IoDispatcher +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.di.IoDispatcher -import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository -import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository +import com.tomasrepcik.sensorbox.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.recording.preview.DevicePreviewRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/settings/ThemeModeSetting.kt similarity index 93% rename from app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt rename to app/src/main/java/com/tomasrepcik/sensorbox/settings/ThemeModeSetting.kt index c0e4d4b..6eaff6e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/settings/ThemeModeSetting.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.settings import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/app/src/main/res/drawable/ic_baseline_home_24.xml b/app/src/main/res/drawable/ic_baseline_home_24.xml deleted file mode 100644 index 3a4c7da..0000000 --- a/app/src/main/res/drawable/ic_baseline_home_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_gps.xml b/app/src/main/res/drawable/ic_gps.xml deleted file mode 100644 index 5283f29..0000000 --- a/app/src/main/res/drawable/ic_gps.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97cc84f..d0a21ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ SensorBox Next - Agree and continue Back Close Something went wrong @@ -16,21 +15,14 @@ The operation failed. The error was saved in diagnostics. Yes No - On - Off Unknown Unavailable Waiting… - Ready Granted Required Choose Change - View - Hide - Record - Settings About Welcome to SensorBox @@ -69,7 +61,6 @@ Elapsed time Sources GPS - Sensor list Show sensor list Hide sensor list Sensor %1$d @@ -83,24 +74,11 @@ Measurements are written to the recording archive you choose through Android’s system document picker. SensorBox does not upload recorded sensor or location data. - Pick sensors - Phone and Wear OS ready - Choose recording sources Options - Sensors - Phone Wear OS - %1$d of %2$d selected - Device location Information about GPS Information about %1$s - i - Step 1 of 2 Continue - - %1$d sensor - %1$d sensors - %1$d source %1$d sources @@ -126,15 +104,22 @@ Measurement file Folder Started - %1$d data files + + %1$d data file + %1$d data files + Sensor samples Sensor details GPS coordinates Text data Loading measurements… + Loading measurement… %1$d%% The recording archive could not be read. No measurements were found in the recording archive. - %1$d chart samples + + %1$d chart sample + %1$d chart samples + Zoom out Show all Zoom in @@ -142,20 +127,19 @@ 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 + + %1$d coordinate + %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. Recording settings - Control how recordings run Recording setup - %1$s ready Recording archive Choose the phone directory for measurements - Recording archive required - Ready to record Start recording Starting recording… @@ -223,14 +207,8 @@ + - Sensor info Sensor unavailable - It may have been disconnected Preview - GPS preview - %1$s preview - Live location values - Live sensor values This sensor could not be activated. Waiting for sensor data… Live chart @@ -244,7 +222,6 @@ Z W - Name Version Vendor Resolution [%1$s] @@ -285,10 +262,7 @@ steps Diagnostics - Errors are stored locally and shared only when you choose to export them. Share diagnostics - Send as text - Send as file View logs Open the complete diagnostic history Share the diagnostic log file @@ -296,11 +270,8 @@ Diagnostic logs Loading logs… No diagnostic logs recorded - Copy logs Clear logs Delete both retained diagnostic log files? This cannot be undone. Diagnostics copied. Diagnostics cleared. - The diagnostics operation failed. - Diagnostics could not be shared. The error was saved for a later attempt. diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml index 6fd6103..ce0b324 100644 --- a/app/src/main/res/xml/backup_descriptor.xml +++ b/app/src/main/res/xml/backup_descriptor.xml @@ -1,4 +1,8 @@ - + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index fe7a40d..b853a8d 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,6 +1,25 @@ - + + + + + + + + + - \ No newline at end of file + + + + + + + + + + + + diff --git a/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviews.kt b/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviews.kt similarity index 98% rename from app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviews.kt rename to app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviews.kt index ea30195..c0d3f1d 100644 --- a/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviews.kt +++ b/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviews.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.branding +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviews.kt b/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviews.kt similarity index 86% rename from app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviews.kt rename to app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviews.kt index c86670e..0a5d97a 100644 --- a/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviews.kt +++ b/app/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviews.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.navigation import android.content.res.Configuration import android.hardware.Sensor @@ -9,8 +9,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.android.tools.screenshot.PreviewTest import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import com.tomasrepcik.sensorbox.design.SensorBoxTheme +import com.tomasrepcik.sensorbox.onboarding.OnboardingScreen +import com.tomasrepcik.sensorbox.onboarding.OnboardingState +import com.tomasrepcik.sensorbox.recording.RecordingState +import com.tomasrepcik.sensorbox.recording.active.RecordScreen +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetupScreen +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor @PreviewTest @ReadmePhonePreview diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/darkSplashScreen_ab3426bf_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/darkSplashScreen_ab3426bf_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/darkSplashScreen_ab3426bf_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/darkSplashScreen_ab3426bf_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/launcherIconShapes_877be636_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/launcherIconShapes_877be636_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/launcherIconShapes_877be636_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/launcherIconShapes_877be636_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/lightSplashScreen_ab3426bf_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/lightSplashScreen_ab3426bf_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/branding/BrandingScreenshotPreviewsKt/lightSplashScreen_ab3426bf_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/design/BrandingScreenshotPreviewsKt/lightSplashScreen_ab3426bf_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroBattery_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroBattery_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroBattery_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroBattery_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroLifecycle_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroLifecycle_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroLifecycle_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroLifecycle_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroPolicy_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroPolicy_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroPolicy_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroPolicy_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroPrivacy_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroPrivacy_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroPrivacy_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroPrivacy_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroStorage_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroStorage_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroStorage_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroStorage_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroWelcome_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroWelcome_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeIntroWelcome_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeIntroWelcome_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeMeasurementSetup_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeRecordingSetup_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeMeasurementSetup_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeRecordingSetup_fa9a8b93_0.png diff --git a/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeSourceSelection_fa9a8b93_0.png b/app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeSourceSelection_fa9a8b93_0.png similarity index 100% rename from app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/main/ReadmePhoneScreenshotPreviewsKt/readmeSourceSelection_fa9a8b93_0.png rename to app/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/navigation/ReadmePhoneScreenshotPreviewsKt/readmeSourceSelection_fa9a8b93_0.png diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/about/PolicyLinksTest.kt similarity index 96% rename from app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/about/PolicyLinksTest.kt index ee8b5fa..a8dbd3c 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/about/PolicyLinksTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.about import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/MeasurementTestFixtures.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/MeasurementTestFixtures.kt new file mode 100644 index 0000000..6301b3f --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/MeasurementTestFixtures.kt @@ -0,0 +1,64 @@ +package com.tomasrepcik.sensorbox.measurements + +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementDetails +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileKind +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileSummary +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementRepository +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary +import com.tomasrepcik.sensorbox.measurements.storage.SensorSeriesSample +import kotlinx.coroutines.CompletableDeferred + +internal object MeasurementTestFixtures { + val summary = MeasurementSummary("session-1", "Morning walk", 1_725_000_000_000, "2026-08-25", 1) + val sensorFile = MeasurementFileSummary( + "accelerometer.csv", + "Accelerometer", + MeasurementFileKind.SENSOR, + 8_192, + ) + val sensorMetadata = listOf(MeasurementMetadataEntry("sensor", "Bosch accelerometer")) + val details = MeasurementDetails( + summary = summary, + metadata = listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")), + files = listOf(sensorFile), + sensorMetadataByFile = mapOf(sensorFile.id to sensorMetadata), + ) + val sensorContent = 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, + ) +} + +internal class FakeMeasurementRepository : MeasurementRepository { + var measurementsResult: AppResult> = + AppResult.success(listOf(MeasurementTestFixtures.summary)) + var detailsResult: AppResult = AppResult.success(MeasurementTestFixtures.details) + var fileResult: AppResult = AppResult.success(MeasurementTestFixtures.sensorContent) + var fileLoadGate: CompletableDeferred? = null + var fileProgress = listOf(1f) + + override suspend fun loadMeasurements(): AppResult> = measurementsResult + + override suspend fun loadMeasurementDetails(measurementId: String): AppResult = detailsResult + + override suspend fun loadMeasurementFile( + measurementId: String, + fileId: String, + onProgress: (Float) -> Unit, + ): AppResult { + fileProgress.forEach(onProgress) + fileLoadGate?.await() + return fileResult + } +} + +internal fun measurementFailureStore() = AppFailureStore(DiagnosticLogger { }) diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModelTest.kt new file mode 100644 index 0000000..c56d17a --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModelTest.kt @@ -0,0 +1,52 @@ +package com.tomasrepcik.sensorbox.measurements.details + +import com.tomasrepcik.sensorbox.measurements.FakeMeasurementRepository +import com.tomasrepcik.sensorbox.measurements.MeasurementTestFixtures +import com.tomasrepcik.sensorbox.measurements.measurementFailureStore +import com.tomasrepcik.sensorbox.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MeasurementDetailsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given a measurement identifier When loaded Then details belong to this screen`() = runTest { + // Given + val viewModel = MeasurementDetailsViewModel(FakeMeasurementRepository(), measurementFailureStore()) + + // When + viewModel.accept(MeasurementDetailsIntent.Load(MeasurementTestFixtures.summary.id)) + advanceUntilIdle() + + // Then + assertEquals(MeasurementTestFixtures.details, viewModel.state.value.details) + } + + @Test + fun `Given loaded details When a file opens Then loading receives its identifiers`() = runTest { + // Given + val viewModel = MeasurementDetailsViewModel(FakeMeasurementRepository(), measurementFailureStore()) + viewModel.accept(MeasurementDetailsIntent.Load(MeasurementTestFixtures.summary.id)) + advanceUntilIdle() + + // When + viewModel.accept(MeasurementDetailsIntent.OpenFile(MeasurementTestFixtures.sensorFile.id)) + + // Then + assertEquals( + MeasurementDetailsEffect.OpenFile( + MeasurementTestFixtures.summary.id, + MeasurementTestFixtures.sensorFile.id, + ), + viewModel.effects.first(), + ) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModelTest.kt new file mode 100644 index 0000000..1d3b846 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsViewModelTest.kt @@ -0,0 +1,72 @@ +package com.tomasrepcik.sensorbox.measurements.list + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.measurements.FakeMeasurementRepository +import com.tomasrepcik.sensorbox.measurements.MeasurementTestFixtures +import com.tomasrepcik.sensorbox.measurements.measurementFailureStore +import com.tomasrepcik.sensorbox.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 MeasurementsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given saved measurements When refreshed Then the list screen exposes them`() = runTest { + // Given + val repository = FakeMeasurementRepository() + val viewModel = MeasurementsViewModel(repository, measurementFailureStore()) + + // When + viewModel.accept(MeasurementsIntent.Refresh) + advanceUntilIdle() + + // Then + assertEquals(listOf(MeasurementTestFixtures.summary), viewModel.state.value.measurements) + assertFalse(viewModel.state.value.isLoading) + } + + @Test + fun `Given a listed measurement When opened Then its identifier is sent to details`() = runTest { + // Given + val viewModel = MeasurementsViewModel(FakeMeasurementRepository(), measurementFailureStore()) + + // When + viewModel.accept(MeasurementsIntent.OpenDetails(MeasurementTestFixtures.summary.id)) + + // Then + assertEquals( + MeasurementsEffect.OpenDetails(MeasurementTestFixtures.summary.id), + viewModel.effects.first(), + ) + } + + @Test + fun `Given repository failure When refreshed Then loading stops with an error`() = runTest { + // Given + val repository = FakeMeasurementRepository().apply { + measurementsResult = AppResult.failure( + AppError(AppErrorCode.STORAGE, "Read fixture measurement repository"), + ) + } + val viewModel = MeasurementsViewModel(repository, measurementFailureStore()) + + // When + viewModel.accept(MeasurementsIntent.Refresh) + advanceUntilIdle() + + // Then + assertFalse(viewModel.state.value.isLoading) + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModelTest.kt new file mode 100644 index 0000000..bf1868f --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewViewModelTest.kt @@ -0,0 +1,100 @@ +package com.tomasrepcik.sensorbox.measurements.preview + +import com.tomasrepcik.sensorbox.measurements.FakeMeasurementRepository +import com.tomasrepcik.sensorbox.measurements.MeasurementTestFixtures +import com.tomasrepcik.sensorbox.measurements.measurementFailureStore +import com.tomasrepcik.sensorbox.testing.MainDispatcherRule +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MeasurementPreviewViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given a preview route When opened Then CSV data and metadata are loaded`() = runTest { + // Given + val viewModel = viewModel() + + // When + viewModel.accept( + MeasurementPreviewIntent.Load( + MeasurementTestFixtures.summary.id, + MeasurementTestFixtures.sensorFile.id, + ), + ) + advanceUntilIdle() + + // Then + assertEquals(MeasurementTestFixtures.sensorFile, viewModel.state.value.file) + assertEquals(MeasurementTestFixtures.sensorContent, viewModel.state.value.content) + assertEquals(MeasurementTestFixtures.sensorMetadata, viewModel.state.value.sensorMetadata) + } + + @Test + fun `Given a large CSV When preview opens Then progress is shown on that screen`() = runTest { + // Given + val repository = FakeMeasurementRepository().apply { + fileLoadGate = CompletableDeferred() + fileProgress = listOf(0.21f, 0.42f) + } + val viewModel = viewModel(repository) + + // When + viewModel.accept( + MeasurementPreviewIntent.Load( + MeasurementTestFixtures.summary.id, + MeasurementTestFixtures.sensorFile.id, + ), + ) + runCurrent() + + // Then + assertTrue(viewModel.state.value.isLoading) + assertEquals(0.42f, viewModel.state.value.progress) + assertEquals(MeasurementTestFixtures.sensorFile, viewModel.state.value.file) + assertNull(viewModel.state.value.content) + + repository.fileLoadGate?.complete(Unit) + advanceUntilIdle() + assertFalse(viewModel.state.value.isLoading) + assertEquals(1f, viewModel.state.value.progress) + assertEquals(MeasurementTestFixtures.sensorContent, viewModel.state.value.content) + } + + @Test + fun `Given a chart When zoomed at its center Then preview halves the visible time`() = runTest { + // Given + val viewModel = viewModel() + viewModel.accept( + MeasurementPreviewIntent.Load( + MeasurementTestFixtures.summary.id, + MeasurementTestFixtures.sensorFile.id, + ), + ) + advanceUntilIdle() + + // When + viewModel.accept(MeasurementPreviewIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) + + // Then + assertEquals(SensorChartTimeWindow(0.25f, 0.75f), viewModel.state.value.sensorChartTimeWindow) + } + + private fun viewModel( + repository: FakeMeasurementRepository = FakeMeasurementRepository(), + ): MeasurementPreviewViewModel = MeasurementPreviewViewModel( + repository = repository, + appFailures = measurementFailureStore(), + ) +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/ChartSampleIndexesTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/ChartSampleIndexesTest.kt new file mode 100644 index 0000000..0a2fb39 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/ChartSampleIndexesTest.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.measurements.storage + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ChartSampleIndexesTest { + @Test + fun `Given excess samples When indexes are selected Then the complete recording is represented`() { + // Given + val totalSamples = 10 + val chartLimit = 4 + + // When + val indexes = chartSampleIndexes(totalSamples, chartLimit) + + // Then + assertArrayEquals(intArrayOf(0, 3, 6, 9), indexes) + } + + @Test + fun `Given fewer samples than the chart limit When indexes are selected Then every sample is represented`() { + // Given + val totalSamples = 4 + val chartLimit = 10 + + // When + val indexes = chartSampleIndexes(totalSamples, chartLimit) + + // Then + assertArrayEquals(intArrayOf(0, 1, 2, 3), indexes) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParserTest.kt similarity index 94% rename from app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParserTest.kt index 6684f53..75c198a 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/MeasurementMetadataParserTest.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.data.measurements +package com.tomasrepcik.sensorbox.measurements.storage -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import com.tomasrepcik.sensorbox.measurements.storage.MeasurementMetadataEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Test diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/navigation/FeatureViewModelTest.kt similarity index 67% rename from app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/navigation/FeatureViewModelTest.kt index fe39c88..518000c 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/navigation/FeatureViewModelTest.kt @@ -1,30 +1,44 @@ -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.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore +package com.tomasrepcik.sensorbox.navigation + +import com.tomasrepcik.sensorbox.about.OpenSourceLicense +import com.tomasrepcik.sensorbox.about.OpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode import com.tomasrepcik.sensorbox.core.testing.FakeAppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFile -import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense -import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository -import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository -import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData -import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository -import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection -import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSources -import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.diagnostics.DiagnosticsShareFile +import com.tomasrepcik.sensorbox.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.onboarding.OnboardingEffect +import com.tomasrepcik.sensorbox.onboarding.OnboardingIntent +import com.tomasrepcik.sensorbox.onboarding.OnboardingViewModel +import com.tomasrepcik.sensorbox.recording.RecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.RecordingDraft +import com.tomasrepcik.sensorbox.recording.RecordingMessage +import com.tomasrepcik.sensorbox.recording.active.ActiveRecordingEffect +import com.tomasrepcik.sensorbox.recording.active.ActiveRecordingIntent +import com.tomasrepcik.sensorbox.recording.active.ActiveRecordingViewModel +import com.tomasrepcik.sensorbox.recording.active.RecordViewModel +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.recording.archive.RecordingArchiveSelection +import com.tomasrepcik.sensorbox.recording.preview.DevicePreviewRepository +import com.tomasrepcik.sensorbox.recording.preview.GpsPreviewData +import com.tomasrepcik.sensorbox.recording.preview.SensorPreviewData import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetupEffect +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetupIntent +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetupViewModel +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSources +import com.tomasrepcik.sensorbox.recording.sources.AvailableRecordingSourcesUseCase +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor +import com.tomasrepcik.sensorbox.settings.SettingsIntent +import com.tomasrepcik.sensorbox.settings.SettingsViewModel import com.tomasrepcik.sensorbox.testing.MainDispatcherRule import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -162,10 +176,10 @@ class FeatureViewModelTest { @Test fun `Given no selected source When recording starts Then recording exposes validation code`() = runTest { - val viewModel = recordingViewModel(FakeRecordingWorkflow()) + val viewModel = recordingSetupViewModel(FakeRecordingWorkflow()) advanceUntilIdle() - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.StartRecording) assertEquals(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, viewModel.state.value.message) assertEquals(AppErrorCode.VALIDATION, viewModel.state.value.errorCode) @@ -178,22 +192,41 @@ class FeatureViewModelTest { val archive = FakeRecordingArchiveRepository( selectedResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Check fixture archive")), ) - val viewModel = recordingViewModel( + val viewModel = recordingSetupViewModel( workflow = FakeRecordingWorkflow(), archive = archive, appFailures = appFailures, ) advanceUntilIdle() - viewModel.accept(RecordingIntent.ToggleSensor(1)) + viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1)))) // When - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.StartRecording) // Then assertEquals(AppErrorCode.STORAGE, appFailures.visibleFailure.value?.code) assertEquals(RecordingMessage.NONE, viewModel.state.value.message) } + @Test + fun `Given archive selected after recording state was created When refreshed Then recording uses its path`() = + runTest { + // Given + val archive = FakeRecordingArchiveRepository(isSelected = false) + val viewModel = recordingSetupViewModel( + workflow = FakeRecordingWorkflow(), + archive = archive, + ) + assertEquals(null, viewModel.state.value.recordingArchivePath) + archive.select(RecordingArchiveSelection.Selected("fixture", 0)) + + // When + viewModel.refreshRecordingArchive() + + // Then + assertEquals("fixture", viewModel.state.value.recordingArchivePath) + } + @Test fun `Given watch permission rejection When recording starts Then watch guidance is shown`() = runTest { val workflow = FakeRecordingWorkflow().apply { @@ -206,11 +239,11 @@ class FeatureViewModelTest { ), ) } - val viewModel = recordingViewModel(workflow) + val viewModel = recordingSetupViewModel(workflow) advanceUntilIdle() - viewModel.accept(RecordingIntent.ToggleWatchSensor(1)) - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedWatchSensorIds = setOf(1)))) + viewModel.accept(RecordingSetupIntent.StartRecording) advanceUntilIdle() assertEquals(RecordingMessage.WATCH_PERMISSION_REQUIRED, viewModel.state.value.message) @@ -222,15 +255,15 @@ class FeatureViewModelTest { val startGate = CompletableDeferred() val workflow = FakeRecordingWorkflow().apply { this.startGate = startGate } val sessionStore = RecordingSessionStore() - val viewModel = recordingViewModel(workflow, sessionStore = sessionStore) + val viewModel = recordingSetupViewModel(workflow, sessionStore = sessionStore) advanceUntilIdle() - viewModel.accept(RecordingIntent.ToggleSensor(1)) - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1)))) + viewModel.accept(RecordingSetupIntent.StartRecording) runCurrent() assertTrue(viewModel.state.value.isStarting) - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.StartRecording) assertEquals(1, workflow.startCalls) startGate.complete(Unit) @@ -247,17 +280,46 @@ class FeatureViewModelTest { runCurrent() } + @Test + fun `Given source start failure When recording stops before running Then loading stops`() = runTest { + // Given + val sessionStore = RecordingSessionStore() + val appFailures = failureStore() + val viewModel = recordingSetupViewModel( + workflow = FakeRecordingWorkflow(), + sessionStore = sessionStore, + appFailures = appFailures, + ) + advanceUntilIdle() + viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1)))) + viewModel.accept(RecordingSetupIntent.StartRecording) + runCurrent() + assertTrue(viewModel.state.value.isStarting) + + // When + sessionStore.publishStopped( + sessionId = "failed-session", + reason = RecordingSessionStopReason.SOURCE_FAILURE, + result = AppResult.failure(AppError(AppErrorCode.RECORDING, "Start sensors")), + ) + runCurrent() + + // Then + assertFalse(viewModel.state.value.isStarting) + assertEquals(AppErrorCode.RECORDING, appFailures.visibleFailure.value?.code) + } + @Test fun `Given a start delay When recording starts Then ViewModel counts down before execution`() = runTest { // Given val recording = FakeRecordingWorkflow() - val viewModel = recordingViewModel(recording) + val viewModel = recordingSetupViewModel(recording) advanceUntilIdle() - viewModel.accept(RecordingIntent.ToggleSensor(1)) - viewModel.accept(RecordingIntent.SetStartDelay(2)) + viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1)))) + viewModel.accept(RecordingSetupIntent.SetStartDelay(2)) // When - viewModel.accept(RecordingIntent.StartRecording) + viewModel.accept(RecordingSetupIntent.StartRecording) runCurrent() // Then @@ -275,29 +337,34 @@ class FeatureViewModelTest { @Test fun `Given recording archive intent When chosen Then recording emits only its picker effect`() = runTest { - val viewModel = recordingViewModel(FakeRecordingWorkflow()) + val viewModel = recordingSetupViewModel(FakeRecordingWorkflow()) val effect = async { viewModel.effects.first() } - viewModel.accept(RecordingIntent.ChooseRecordingArchive) + viewModel.accept(RecordingSetupIntent.ChooseRecordingArchive) - assertEquals(RecordingEffect.PickRecordingArchive, effect.await()) + assertEquals(RecordingSetupEffect.PickRecordingArchive, effect.await()) } @Test fun `Given an active recording When stopped Then recording navigates to the main screen`() = runTest { - val viewModel = recordingViewModel(FakeRecordingWorkflow()) + val viewModel = ActiveRecordingViewModel( + recording = FakeRecordingWorkflow(), + sessionStore = RecordingSessionStore(), + elapsedRealtimeClock = { 10_000L }, + appFailures = failureStore(), + ) val effect = async { viewModel.effects.first() } - viewModel.accept(RecordingIntent.StopRecording) + viewModel.accept(ActiveRecordingIntent.StopRecording) advanceUntilIdle() - assertEquals(RecordingEffect.Navigate(MainRoute.RECORD), effect.await()) + assertEquals(ActiveRecordingEffect.RecordingStopped, effect.await()) } @Test fun `Given available watch sources When observed Then every sensor detail is retained`() = runTest { val sources = FakeAvailableRecordingSources() - val viewModel = recordingViewModel(FakeRecordingWorkflow(), sources) + val viewModel = RecordViewModel(sources, failureStore()) advanceUntilIdle() sources.update( @@ -315,7 +382,7 @@ class FeatureViewModelTest { power = 0.25f, minimumDelayMicros = 5_000, maximumDelayMicros = 200_000, - reportingMode = com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode.CONTINUOUS, + reportingMode = com.tomasrepcik.sensorbox.recording.sources.SensorReportingMode.CONTINUOUS, isWakeUpSensor = true, ), ), @@ -336,29 +403,25 @@ class FeatureViewModelTest { power = 0.25f, minimumDelayMicros = 5_000, maximumDelayMicros = 200_000, - reportingMode = com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode.CONTINUOUS, + reportingMode = com.tomasrepcik.sensorbox.recording.sources.SensorReportingMode.CONTINUOUS, isWakeUpSensor = true, ), viewModel.state.value.watchSensors.single(), ) } - private fun recordingViewModel( + private fun recordingSetupViewModel( workflow: RecordingControlUseCase, - availableSources: AvailableRecordingSourcesUseCase = FakeAvailableRecordingSources(), sessionStore: RecordingSessionStore = RecordingSessionStore(), archive: RecordingArchiveRepository = FakeRecordingArchiveRepository(isSelected = true), appFailures: AppFailureStore = failureStore(), - ): RecordingViewModel = RecordingViewModel( + ): RecordingSetupViewModel = RecordingSetupViewModel( preferencesRepository = FakeAppPreferencesRepository(), - availableSources = availableSources, recordingArchive = archive, - permissions = RecordingPermissionsUseCase { emptySet() }, + permissions = { emptySet() }, recording = workflow, sessionStore = sessionStore, - elapsedRealtimeClock = ElapsedRealtimeClock { 10_000L }, appFailures = appFailures, - devicePreview = FakeDevicePreviewRepository(), ) private fun failureStore() = AppFailureStore(DiagnosticLogger { }) @@ -381,16 +444,21 @@ private class FakeAvailableRecordingSources( private class FakeRecordingArchiveRepository( isSelected: Boolean = true, - private val selectedResult: AppResult = AppResult.success(isSelected), + private val selectedResult: AppResult? = null, ) : RecordingArchiveRepository { - override fun isSelected(): AppResult = selectedResult + private var selected = isSelected + + override fun isSelected(): AppResult = selectedResult ?: AppResult.success(selected) - override fun path(): AppResult = selectedResult.fold( - onSuccess = { AppResult.success(if (it) "fixture" else null) }, + override fun path(): AppResult = isSelected().fold( + onSuccess = { AppResult.success(if (selected) "fixture" else null) }, onFailure = { AppResult.success(null) }, ) - override fun select(selection: RecordingArchiveSelection.Selected): AppResult = AppResult.success(Unit) + override fun select(selection: RecordingArchiveSelection.Selected): AppResult { + selected = true + return AppResult.success(Unit) + } } private class FakeDiagnosticsStore( 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 deleted file mode 100644 index 6dc4752..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserReducerTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 086e7d5..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -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.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -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, failureStore()) - - 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, failureStore()) - 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, failureStore()) - - viewModel.onIntent(MeasurementBrowserIntent.RefreshMeasurements) - advanceUntilIdle() - - assertFalse(viewModel.state.value.isLoading) - assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) - } - - private fun failureStore() = AppFailureStore(DiagnosticLogger { }) -} - -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 deleted file mode 100644 index 61bb469..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingReducerTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class RecordingReducerTest { - @Test - fun `Given an unselected sensor When toggled Then it becomes selected`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleSensor(sensorId = 1)) - - assertTrue(1 in actual.state.selectedSensorIds) - } - - @Test - fun `Given record state When recording archive is requested Then picker effect is emitted`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.ChooseRecordingArchive) - - assertEquals(RecordingEffect.PickRecordingArchive, actual.effect) - } - - @Test - fun `Given GPS disabled When toggled Then GPS becomes enabled`() { - val givenState = RecordingStateFixtures.state(includesGps = false) - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleGps) - - assertTrue(actual.state.includesGps) - } - - @Test - fun `Given an unselected Wear sensor When toggled Then only Wear selection changes`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleWatchSensor(sensorId = 21)) - - assertTrue(21 in actual.state.selectedWatchSensorIds) - assertTrue(actual.state.selectedSensorIds.isEmpty()) - } - - @Test - fun `Given a negative start delay When changed Then it is clamped to zero`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetStartDelay(-1)) - - assertEquals(0, actual.state.startDelaySeconds) - } - - @Test - fun `Given a negative duration When changed Then it is clamped to zero`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetDuration(-1)) - - assertEquals(0, actual.state.durationSeconds) - } - - @Test - fun `Given selected sensors When setup is opened Then setup route is shown`() { - val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.OpenRecordingSetup) - - assertEquals(RecordingEffect.Navigate(MainRoute.RECORDING_SETUP), actual.effect) - } - - @Test - fun `Given a sensor When its information is opened Then details route retains its type`() { - val givenState = RecordingStateFixtures.state() - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.OpenSensorDetails(sensorType = 1)) - - assertEquals(RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), actual.effect) - 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, RecordingDevice.WATCH), - ) - - assertEquals(RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), actual.effect) - assertEquals(21, actual.state.detailsSensorType) - assertEquals(RecordingDevice.WATCH, actual.state.detailsDevice) - } - - @Test - fun `Given recording setup When returning Then sensor selection is shown`() { - val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) - - val actual = RecordingReducer.reduce(givenState, RecordingIntent.ReturnToSensorSelection) - - assertEquals(RecordingEffect.Navigate(MainRoute.RECORD), actual.effect) - } -} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCaseTest.kt similarity index 92% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCaseTest.kt index 7c208f1..ba27a27 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingControlUseCaseTest.kt @@ -1,13 +1,15 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup +import com.tomasrepcik.sensorbox.recording.setup.StartedPhoneRecording +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCaseTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCaseTest.kt similarity index 92% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCaseTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCaseTest.kt index f6a230a..e13806c 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingPermissionUseCaseTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingPermissionUseCaseTest.kt @@ -1,6 +1,7 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest +import com.tomasrepcik.sensorbox.recording.setup.RecordingSetup import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateConversionTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateConversionTest.kt similarity index 96% rename from app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateConversionTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateConversionTest.kt index 3c537c8..f407d23 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateConversionTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateConversionTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateFixtures.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateFixtures.kt similarity index 74% rename from app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateFixtures.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateFixtures.kt index dfedee4..c05a910 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/RecordingStateFixtures.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/RecordingStateFixtures.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.presentation.main +package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.recording.sources.SensorDescriptor object RecordingStateFixtures { fun state(selectedSensorIds: Set = emptySet(), includesGps: Boolean = false) = RecordingState( diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/recording/active/RecordReducerTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/active/RecordReducerTest.kt new file mode 100644 index 0000000..e94a2e2 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/active/RecordReducerTest.kt @@ -0,0 +1,74 @@ +package com.tomasrepcik.sensorbox.recording.active + +import com.tomasrepcik.sensorbox.recording.RecordingDevice +import com.tomasrepcik.sensorbox.recording.RecordingStateFixtures +import com.tomasrepcik.sensorbox.recording.toDraft +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordReducerTest { + @Test + fun `Given an unselected sensor When toggled Then it becomes selected`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordReducer.reduce(givenState, RecordIntent.ToggleSensor(sensorId = 1)) + + assertTrue(1 in actual.state.selectedSensorIds) + } + + @Test + fun `Given GPS disabled When toggled Then GPS becomes enabled`() { + val givenState = RecordingStateFixtures.state(includesGps = false) + + val actual = RecordReducer.reduce(givenState, RecordIntent.ToggleGps) + + assertTrue(actual.state.includesGps) + } + + @Test + fun `Given an unselected watch sensor When toggled Then only watch selection changes`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordReducer.reduce(givenState, RecordIntent.ToggleWatchSensor(sensorId = 21)) + + assertTrue(21 in actual.state.selectedWatchSensorIds) + assertTrue(actual.state.selectedSensorIds.isEmpty()) + } + + @Test + fun `Given selected sensors When continuing Then their draft is opened`() { + val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) + + val actual = RecordReducer.reduce(givenState, RecordIntent.OpenRecordingSetup) + + assertEquals(RecordEffect.OpenRecordingSetup(givenState.toDraft()), actual.effect) + } + + @Test + fun `Given a phone sensor When its information is opened Then its source is emitted`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordReducer.reduce(givenState, RecordIntent.OpenSensorDetails(sensorType = 1)) + + assertEquals( + RecordEffect.OpenSensorDetails(1, RecordingDevice.PHONE), + actual.effect, + ) + } + + @Test + fun `Given a watch sensor When its information is opened Then its source is emitted`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordReducer.reduce( + givenState, + RecordIntent.OpenSensorDetails(21, RecordingDevice.WATCH), + ) + + assertEquals( + RecordEffect.OpenSensorDetails(21, RecordingDevice.WATCH), + actual.effect, + ) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/archive/DocumentRecordingArchiveRepositoryTest.kt similarity index 95% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/archive/DocumentRecordingArchiveRepositoryTest.kt index 27ab0ff..a9443ed 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/archive/DocumentRecordingArchiveRepositoryTest.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording.archive -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupReducerTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupReducerTest.kt new file mode 100644 index 0000000..5527a8d --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupReducerTest.kt @@ -0,0 +1,51 @@ +package com.tomasrepcik.sensorbox.recording.setup + +import com.tomasrepcik.sensorbox.recording.RecordingDraft +import com.tomasrepcik.sensorbox.recording.RecordingStateFixtures +import com.tomasrepcik.sensorbox.recording.toDraft +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecordingSetupReducerTest { + @Test + fun `Given a negative start delay When changed Then it is clamped to zero`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingSetupReducer.reduce( + givenState, + RecordingSetupIntent.SetStartDelay(-1), + ) + + assertEquals(0, actual.startDelaySeconds) + } + + @Test + fun `Given a negative duration When changed Then it is clamped to zero`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingSetupReducer.reduce( + givenState, + RecordingSetupIntent.SetDuration(-1), + ) + + assertEquals(0, actual.durationSeconds) + } + + @Test + fun `Given a picker draft When setup loads Then it receives every recording detail`() { + val givenDraft = RecordingDraft( + selectedSensorIds = setOf(1, 2), + includesGps = true, + selectedWatchSensorIds = setOf(21), + customMeasurementName = "Morning walk", + notes = "Hill route", + ) + + val actual = RecordingSetupReducer.reduce( + RecordingStateFixtures.state(), + RecordingSetupIntent.LoadDraft(givenDraft), + ) + + assertEquals(givenDraft, actual.toDraft()) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetupTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTest.kt similarity index 97% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetupTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTest.kt index 9abf379..ed38d8a 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingSetupTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording.setup import android.hardware.SensorManager import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/PhoneSensorTypesTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/PhoneSensorTypesTest.kt similarity index 85% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/PhoneSensorTypesTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/PhoneSensorTypesTest.kt index bcb70ac..bfce8f9 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/PhoneSensorTypesTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/PhoneSensorTypesTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.hardware.Sensor import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailabilityTest.kt similarity index 83% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailabilityTest.kt index e88b18e..27e64e9 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/RecordingSourceAvailabilityTest.kt @@ -1,14 +1,14 @@ -package com.tomasrepcik.sensorbox.domain.sensors - -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.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 com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +package com.tomasrepcik.sensorbox.recording.sources + +import com.tomasrepcik.sensorbox.wearoslib.connection.FakeWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/SensorReportingModeTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/SensorReportingModeTest.kt similarity index 93% rename from app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/SensorReportingModeTest.kt rename to app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/SensorReportingModeTest.kt index 80185ae..ccc9aef 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/SensorReportingModeTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/sources/SensorReportingModeTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.hardware.Sensor import org.junit.Assert.assertEquals diff --git a/build.gradle.kts b/build.gradle.kts index 0d3d52f..66ec274 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,59 @@ plugins { alias(libs.plugins.screenshot) apply false } +val checkSourceStructure = tasks.register("checkSourceStructure") { + group = "verification" + description = "Checks that production source packages stay feature-first and navigable." + + val productionKotlin = fileTree(rootDir) { + include("*/src/main/**/*.kt") + exclude("*/build/**") + } + inputs.files(productionKotlin) + + doLast { + val packageRoot = "/com/tomasrepcik/sensorbox/" + val forbiddenSegments = setOf( + "activities", + "data", + "di", + "domain", + "handlers", + "main", + "presentation", + "servicecontroller", + "services", + "types", + ) + val filesByPackage = productionKotlin.files.groupBy { sourceFile -> + val sourcePath = sourceFile.relativeTo(rootDir).invariantSeparatorsPath + val module = sourcePath.substringBefore('/') + val packagePath = sourcePath.substringAfter(packageRoot).substringBeforeLast('/') + "$module/$packagePath" + } + + val technicalPackages = filesByPackage.keys.filter { packagePath -> + packagePath.split('/').any { segment -> segment.lowercase() in forbiddenSegments } + } + require(technicalPackages.isEmpty()) { + "Production code uses forbidden technical packages: ${technicalPackages.sorted().joinToString()}" + } + + val crowdedPackages = filesByPackage.filterValues { files -> files.size > 12 } + require(crowdedPackages.isEmpty()) { + "Production packages exceed 12 Kotlin files: " + + crowdedPackages.entries.sortedBy { entry -> entry.key }.joinToString { (path, files) -> + "$path (${files.size})" + } + } + } +} + subprojects { + tasks.matching { it.name == "check" }.configureEach { + dependsOn(checkSourceStructure) + } + plugins.withId("dev.detekt") { extensions.configure { buildUponDefaultConfig = true diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppError.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppError.kt similarity index 96% rename from core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppError.kt rename to core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppError.kt index eb42d6b..29fe95c 100644 --- a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppError.kt +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppError.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure data class AppError( val code: AppErrorCode, diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStore.kt similarity index 93% rename from core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt rename to core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStore.kt index 2bc3a10..618fcb7 100644 --- a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStore.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppResult.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppResult.kt similarity index 98% rename from core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppResult.kt rename to core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppResult.kt index 03f6c08..3231a91 100644 --- a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppResult.kt +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/AppResult.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure import kotlinx.coroutines.CancellationException diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/Diagnostics.kt similarity index 94% rename from core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt rename to core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/Diagnostics.kt index 26e9319..320f4fa 100644 --- a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/failure/Diagnostics.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure enum class DiagnosticSeverity { INFO, diff --git a/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStoreTest.kt similarity index 97% rename from core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt rename to core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStoreTest.kt index daf7092..5b4351b 100644 --- a/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt +++ b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/failure/AppFailureStoreTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLogger.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLogger.kt similarity index 84% rename from core/src/main/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLogger.kt rename to core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLogger.kt index 5d1b7fd..3950f5a 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLogger.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLogger.kt @@ -1,6 +1,9 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.diagnostics import android.util.Log +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticSeverity class CompositeDiagnosticLogger(private vararg val loggers: DiagnosticLogger) : DiagnosticLogger { override fun record(event: DiagnosticEvent) { diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnostics.kt similarity index 92% rename from core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt rename to core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnostics.kt index 8657832..da65796 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnostics.kt @@ -1,7 +1,14 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.diagnostics import android.content.Context -import com.tomasrepcik.sensorbox.core.time.ClockFormats +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticSeverity +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.format.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.core.time.SystemEpochClock import kotlinx.datetime.DatePeriod diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/time/ClockFormats.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/format/ClockFormats.kt similarity index 96% rename from core/src/main/java/com/tomasrepcik/sensorbox/core/time/ClockFormats.kt rename to core/src/main/java/com/tomasrepcik/sensorbox/core/format/ClockFormats.kt index 1e1deed..d9b96f5 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/time/ClockFormats.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/format/ClockFormats.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.time +package com.tomasrepcik.sensorbox.core.format import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/AppPreferencesRepository.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/AppPreferencesRepository.kt index 7d7d872..588522d 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/AppPreferencesRepository.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/AppPreferencesRepository.kt @@ -1,6 +1,6 @@ package com.tomasrepcik.sensorbox.core.preferences -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow interface AppPreferencesRepository { diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt index b0a2870..2571023 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt @@ -7,10 +7,10 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -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.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt index 3f32512..e21b2e2 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt @@ -4,11 +4,11 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.documentfile.provider.DocumentFile -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 -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import java.io.InputStream import java.io.OutputStream diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLoggerTest.kt similarity index 86% rename from core/src/test/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt rename to core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLoggerTest.kt index eb90a2c..3fd5b4f 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/CompositeDiagnosticLoggerTest.kt @@ -1,5 +1,9 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.diagnostics +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticSeverity import org.junit.Assert.assertEquals import org.junit.Test diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnosticsTest.kt similarity index 93% rename from core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt rename to core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnosticsTest.kt index fde0b94..c276948 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/diagnostics/FileDiagnosticsTest.kt @@ -1,7 +1,9 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.diagnostics +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticSeverity import com.tomasrepcik.sensorbox.core.time.EpochClock -import kotlinx.datetime.Instant import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -10,6 +12,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.util.concurrent.atomic.AtomicInteger import kotlin.concurrent.thread +import kotlin.time.Instant class FileDiagnosticsTest { @get:Rule @@ -19,7 +22,7 @@ class FileDiagnosticsTest { fun `Given eight calendar days When events are recorded Then only seven dated files remain`() { val directory = temporaryFolder.newFolder("diagnostics") var currentMillis = Instant.parse("2026-08-01T12:00:00Z").toEpochMilliseconds() - val diagnostics = diagnostics(directory, EpochClock { currentMillis }) + val diagnostics = diagnostics(directory) { currentMillis } repeat(8) { day -> diagnostics.record(event(message = "day $day")) diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/AppErrorTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/failure/AppErrorTest.kt similarity index 95% rename from core/src/test/java/com/tomasrepcik/sensorbox/core/error/AppErrorTest.kt rename to core/src/test/java/com/tomasrepcik/sensorbox/core/failure/AppErrorTest.kt index 7972ac2..2956aae 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/AppErrorTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/failure/AppErrorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.error +package com.tomasrepcik.sensorbox.core.failure import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/time/ClockFormatsTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/format/ClockFormatsTest.kt similarity index 94% rename from core/src/test/java/com/tomasrepcik/sensorbox/core/time/ClockFormatsTest.kt rename to core/src/test/java/com/tomasrepcik/sensorbox/core/format/ClockFormatsTest.kt index 8d0a433..351b728 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/time/ClockFormatsTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/format/ClockFormatsTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.core.time +package com.tomasrepcik.sensorbox.core.format import kotlinx.datetime.TimeZone import org.junit.Assert.assertEquals diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt index 4f4cce1..a15dd0d 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt @@ -6,12 +6,12 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.mutablePreferencesOf import androidx.datastore.preferences.core.stringPreferencesKey -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch @@ -23,6 +23,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException +@OptIn(ExperimentalCoroutinesApi::class) class DataStoreAppPreferencesRepositoryTest { @Test fun `Given an empty legacy store When preferences are read Then every historical default is preserved`() = runTest { 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 714d91c..f4763f9 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,6 +1,6 @@ package com.tomasrepcik.sensorbox.core.testing -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.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/docs/adr/0002-separate-recording-engine-from-android-adapters.md b/docs/adr/0002-separate-recording-engine-from-android-adapters.md index d23c4e5..239b9c5 100644 --- a/docs/adr/0002-separate-recording-engine-from-android-adapters.md +++ b/docs/adr/0002-separate-recording-engine-from-android-adapters.md @@ -2,6 +2,6 @@ Recording execution, source ordering, failure cleanup, duration limits, and terminal events live in the pure Kotlin `recording-core` module. Callers submit a complete recording request through direct start and stop operations. Android sensor capture, foreground hosting, notifications, wake locks, battery callbacks, GPS, and storage streams remain in `sensorservices`. -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. +The phone owns session decisions when one user recording includes phone and watch sources. `WearOsLib` owns serializable command models, JSON encoding, connection discovery, byte transport, and the acknowledged recording-command exchange. That exchange correlates results by recording session and operation, bounds retries and timeouts, and translates peer failures into application outcomes. The phone and watch applications own command behavior and decide how a recording session reacts to those outcomes. The acknowledged exchange is limited to recording start and stop commands; discovery and result replies remain one-way messages. 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/0005-define-sensorbox-application-architecture.md b/docs/adr/0005-define-sensorbox-application-architecture.md index 5359fe9..0117d79 100644 --- a/docs/adr/0005-define-sensorbox-application-architecture.md +++ b/docs/adr/0005-define-sensorbox-application-architecture.md @@ -1,6 +1,6 @@ # 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 request without Android dependencies. `sensorservices` turns that request 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. +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 request without Android dependencies. `sensorservices` turns that request into Android sensor and storage work. `WearOsLib` owns Wear command models, Google Play transport, and the acknowledged exchange mechanics used by recording start and stop. `app` and `wear` own presentation, recording-session decisions, and the phone or watch behavior behind received commands. Each screen owns immutable state, intents, effects, its ViewModel, and the use cases needed by that ViewModel. Root composables connect Compose to the ViewModel and perform native effects. Child UI renders state and sends intents. Application navigation follows user workflows rather than technical tiers. @@ -9,3 +9,21 @@ Expected failures cross module interfaces as `AppResult` with stable `AppErro 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. + +Stateful implementations expose interfaces named for each caller's intent. One phone recording controller backs UI start and stop, automatic phone-stop handling, and stop requests received from the watch, but each caller sees only its focused interface. Sensor availability similarly owns phone sources, watch discovery, connection state, and received watch sensors behind one observable use case. Android foreground-service lifecycle is coordinated by a host session so the service itself remains an intent and lifecycle adapter. + +## Small-application dependency exception + +SensorBox is a small application with substantial legacy code. A ViewModel may depend directly on a focused repository or use case when another delegation layer would not create a useful seam. This exception does not relax MVI: rendering UI receives immutable state and sends intents, ViewModels own state transitions, repositories return product values, and Android or vendor UI types stay outside ViewModels, contracts, and domain ports. + +New code should make feature ownership visible through focused contracts and packages for shell, onboarding, recording, measurements, and settings. The legacy `presentation.main` package may remain while the application is small; moving files only to satisfy a directory convention is not required. This exception ends when a touched area mixes screen contracts, bypasses its ViewModel, or exposes mutable or platform-owned state. Cross-cutting preferences, diagnostics, clocks, formatting, and theme remain in core modules. Recording session state and events belong to `recording-core`; `sensorservices` only implements Android recording adapters. + +## Failure policy + +Operational failures are recorded and exposed through the process-wide `AppFailureStore`. The first visible failure remains until the user dismisses it; later failures are still recorded. Phone and Wear shells render only localized messages selected by stable `AppErrorCode` values. Diagnostic messages, causes, file paths, and context are never rendered. + +Validation guidance, missing permissions, an unselected archive, an empty source selection, and user cancellation remain local screen outcomes. A source that fails after recording has started is also non-terminal: the engine stops and removes that source, emits `RecordingEvent.SourceFailed`, and continues the session. The foreground host records the source and cleanup errors. Start failures, terminal cleanup failures, and host failures remain operational failures and use the global path. + +## Effects and platform boundaries + +Each state owner delivers one-time effects through a buffered channel exposed as a Flow. Roots may execute native operations such as permission launchers, document pickers, navigation, and share intents, but they do not read repositories or stores. Roots translate native results into typed product values and return launch failures to their ViewModel. Platform adapters hide sensor, location, power, resource, file, and content-provider APIs behind focused ports. diff --git a/docs/recording-architecture-guided-review.md b/docs/recording-architecture-guided-review.md index 517e1f1..fa6218a 100644 --- a/docs/recording-architecture-guided-review.md +++ b/docs/recording-architecture-guided-review.md @@ -26,20 +26,20 @@ The root UI sends intents to `RecordingViewModel`. The ViewModel owns the countd The useful seams injected into the ViewModel are small and named by intent: -- `AvailableSensorsUseCase` +- `AvailableRecordingSourcesUseCase` - `RecordingPermissionsUseCase` - `RecordingArchiveRepository` - `RecordingControlUseCase` -- `SendWearCommandUseCase` Check that these interfaces isolate real side effects or a module boundary. There is no general-purpose workflow gateway. +`AvailableRecordingSourcesUseCase` owns the combined phone and watch source view. Its implementation observes watch connectivity, requests the watch catalog, clears stale watch sources after disconnect, and accepts catalog replies through the separate `ReceiveWatchSensorsUseCase` caller interface. + ## 3. Follow phone recording coordination Read: - `../app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.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/recording/PhoneRecordingController.kt` @@ -48,12 +48,11 @@ The execution path is: ```text RecordingViewModel -> RecordingControlUseCase - -> PairedRecordingCoordinator -> PhoneRecordingController - -> SendWearCommandUseCase (only when watch sources are selected) + -> RecordingCommandSender (only when watch sources are selected) ``` -`PairedRecordingCoordinator` owns one small state gate. It starts the phone first, sends a direct watch start command, and cleans up both sides if the watch fails. It has no prepare/commit/rollback protocol and no mutex. Model-to-command conversions live beside the models in `PairedRecordingModel.kt`. +`DefaultRecordingControlUseCase` owns one small state gate. It starts the phone first, sends a direct watch start command, and cleans up both sides if the watch fails. The same stateful implementation exposes `PhoneRecordingSessionControl` and `PeerRecordingControl`, giving automatic-stop observation and received watch commands only the operations they need. 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 the watch only affects the matching active recording session. @@ -65,9 +64,10 @@ 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/RecordingCommandExchange.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. +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 use `RecordingCommandExchange`, which owns result correlation, bounded retries, timeout reporting, and peer-error mapping on both devices. Other commands remain one-way. No caller should build an ad-hoc payload. ## 5. Follow commands received on each device @@ -75,7 +75,7 @@ Phone side: - `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchMessageDispatcher.kt` - `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt` -- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WatchRecordingResultInbox.kt` +- `../app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt` Wear side: @@ -92,14 +92,15 @@ Dispatchers decode and route. Handlers state-gate commands and execute them. On Read: - `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostSession.kt` - `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt` -- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt` +- `../sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostSessionFactory.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` -`RecordingService` routes lifecycle and intents. `RecordingHostResources` owns Android host concerns such as notification, wake lock, receiver, and alarm. The factory assembles a controller. The controller translates the platform recording request into the engine recording request. The engine then starts or stops ordered `RecordingSource` instances and knows nothing about Android. +`RecordingService` only routes lifecycle and intents. `RecordingHostSession` owns the stateful start, stop, event, and cleanup sequence. `RecordingHostResources` owns Android host concerns such as notification, wake lock, receiver, and alarm. The factory assembles the host session and controller. The controller translates the platform recording request into the engine recording request. 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(context)`. @@ -134,7 +135,10 @@ Read: 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` +- `../WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchangeTest.kt` +- `../app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt` +- `../app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt` +- `../sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostSessionTest.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` diff --git a/gradle.properties b/gradle.properties index c9c2b79..65edada 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,7 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true android.experimental.enableScreenshotTest=true +android.sync.suppressAgpWarnings=UNSUPPORTED_PROJECT_OPTION_USE # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.nonTransitiveRClass=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9da3645..917c4a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,19 +10,20 @@ documentFile = "1.1.0" detekt = "2.0.0-alpha.6" espresso = "3.7.0" hilt = "2.60.1" +hiltAndroidx = "1.4.0" kotlin = "2.4.10" kotlinxDatetime = "0.8.0" kotlinxSerialization = "1.11.0" -ksp = "2.3.10" +ksp = "2.3.11" lifecycle = "2.11.0" junit = "4.13.2" material = "1.14.0" -navigation3 = "1.1.5" +navigation3 = "1.1.7" ossLicensesPlugin = "0.13.0" playServicesLocation = "21.4.0" playServicesWearable = "20.0.1" -screenshot = "0.0.1-alpha15" -vico = "3.2.3" +screenshot = "0.0.1-alpha16" +vico = "3.3.1" wearable = "2.9.0" wearCompose = "1.6.2" wearRemoteInteractions = "1.2.0" @@ -45,6 +46,7 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wearCompose" } @@ -59,6 +61,7 @@ espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = google-material = { module = "com.google.android.material:material", version.ref = "material" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "hiltAndroidx" } junit = { module = "junit:junit", version.ref = "junit" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 69dd0d0..ad7845b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 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 d600281..e1f18cc 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 @@ -1,23 +1,30 @@ package com.tomasrepcik.sensorbox.recording -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.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield class RecordingEngine( sources: List, private val scope: CoroutineScope, private val waitFor: suspend (Long) -> Unit = { delay(it) }, + private val sourceStartDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { private val sourceByType = sources.associateBy(RecordingSource::type) private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) @@ -28,7 +35,7 @@ class RecordingEngine( val events: SharedFlow = mutableEvents.asSharedFlow() @Suppress("ReturnCount") - suspend fun start(request: RecordingRequest): AppResult { + fun start(request: RecordingRequest): AppResult { if (activeRecording != null) return conflict("Start recording", request.sessionId) val validation = validate(request) @@ -38,18 +45,11 @@ class RecordingEngine( lastStopResult = null activeRecording = recording - for (spec in request.sources.sortedBy { it.type.ordinal }) { - val source = checkNotNull(sourceByType[spec.type]) - recording.startedSources += source - - val result = source.start(spec) - if (result is AppResult.Failure) return stopFailedStart(recording, result.error) - if (activeRecording !== recording) return AppResult.success(Unit) - } - - mutableEvents.tryEmit(RecordingEvent.RecordingStarted(request.sessionId)) - scheduleDurationStop(recording) + val sourceSpecs = request.sources.sortedBy { it.type.ordinal } + recording.startedSources += sourceSpecs.map { spec -> checkNotNull(sourceByType[spec.type]) } observeSourceFailures(recording) + scheduleDurationStop(recording) + launchRecordingSources(recording, sourceSpecs) return AppResult.success(Unit) } @@ -59,68 +59,126 @@ class RecordingEngine( } private suspend fun stopRecording(recording: ActiveRecording, context: RecordingStopContext): AppResult { - activeRecording = null - recording.durationStop?.cancel() - recording.failureMonitor?.cancel() - - val stoppedSources = stopSources(recording.startedSources, context) + cancelRecordingJobs(recording) + val stoppedSources = stopRecordingSources(recording.startedSources, context) val results = buildList { context.failures.forEach { add(AppResult.failure(it)) } addAll(stoppedSources) } val result = results.combineAppResults(AppErrorCode.RECORDING, "Stop recording sources") lastStopResult = result + publishRecordingStopped(recording, context.reason, result) + return result + } + + private fun cancelRecordingJobs(recording: ActiveRecording) { + activeRecording = null + recording.durationStop?.cancel() + recording.failureMonitor?.cancel() + recording.sourceStartJobs.values.toList().forEach(Job::cancel) + recording.sourceStartJobs.clear() + } + + private fun publishRecordingStopped( + recording: ActiveRecording, + reason: RecordingStopReason, + result: AppResult, + ) { mutableEvents.tryEmit( RecordingEvent.RecordingStopped( sessionId = recording.request.sessionId, - reason = context.reason, + reason = reason, result = result, ), ) - return result } - private suspend fun stopFailedStart(recording: ActiveRecording, startError: AppError): AppResult { - if (activeRecording !== recording) return lastStopResult ?: AppResult.failure(startError) - activeRecording = null - val stoppedSources = stopSources( - recording.startedSources, - RecordingStopContext(RecordingStopReason.SOURCE_FAILURE, listOf(startError)), - ) - val result = (listOf(AppResult.failure(startError)) + stoppedSources) - .combineAppResults(AppErrorCode.RECORDING, "Stop failed recording start") - return result - } - - private suspend fun stopSources( + private suspend fun stopRecordingSources( sources: List, context: RecordingStopContext, ): List> { var currentContext = context val results = mutableListOf>() for (source in sources.asReversed()) { - val result = source.stop(currentContext) + val result = stopRecordingSource(source, currentContext) results += result result.errorOrNull()?.let { failure -> currentContext = currentContext.withFailure(failure) } } return results } + private suspend fun stopRecordingSource(source: RecordingSource, context: RecordingStopContext): AppResult = + withTimeoutOrNull(SOURCE_STOP_TIMEOUT_MILLIS) { + source.stop(context) + } ?: sourceStopTimeout(source) + + private fun sourceStopTimeout(source: RecordingSource): AppResult = AppResult.failure( + AppError( + code = AppErrorCode.RECORDING, + operation = "Stop ${source.type} recording source", + diagnosticMessage = "Stop timed out after $SOURCE_STOP_TIMEOUT_MILLIS ms", + context = mapOf( + "sourceType" to source.type.name, + "timeoutMillis" to SOURCE_STOP_TIMEOUT_MILLIS.toString(), + ), + isRetryable = true, + ), + ) + + private fun launchRecordingSources(recording: ActiveRecording, specs: List) { + for (spec in specs) { + val source = checkNotNull(sourceByType[spec.type]) + val job = scope.launch(start = CoroutineStart.LAZY) { + yield() + val result = startRecordingSource(source, spec) + recording.sourceStartJobs.remove(source) + if (result is AppResult.Failure && activeRecording === recording) { + handleRecordingSourceFailure(recording, FailedSource(source, result.error)) + } + } + recording.sourceStartJobs[source] = job + job.invokeOnCompletion { recording.sourceStartJobs.remove(source) } + job.start() + } + } + + private suspend fun startRecordingSource(source: RecordingSource, spec: RecordingSourceSpec): AppResult = + withContext(sourceStartDispatcher) { + source.start(spec) + } + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private fun observeSourceFailures(recording: ActiveRecording) { - val failures = recording.startedSources.map(RecordingSource::failures) + val failures = recording.startedSources.map { source -> + source.failures.map { failure -> FailedSource(source, failure) } + } recording.failureMonitor = scope.launch { - val failure = merge(*failures.toTypedArray()).first() - if (activeRecording === recording) { - recording.failureMonitor = null - stopRecording( - recording = recording, - context = RecordingStopContext(RecordingStopReason.SOURCE_FAILURE, listOf(failure)), - ) + merge(*failures.toTypedArray()).collect { failedSource -> + if (activeRecording === recording) { + handleRecordingSourceFailure(recording, failedSource) + } } } } + private suspend fun handleRecordingSourceFailure(recording: ActiveRecording, failedSource: FailedSource) { + if (!recording.startedSources.remove(failedSource.source)) return + recording.sourceStartJobs.remove(failedSource.source)?.cancel() + + val stopResult = stopRecordingSource( + failedSource.source, + RecordingStopContext(RecordingStopReason.SOURCE_FAILURE, listOf(failedSource.failure)), + ) + mutableEvents.emit( + RecordingEvent.SourceFailed( + sessionId = recording.request.sessionId, + sourceType = failedSource.source.type, + failure = failedSource.failure, + stopFailure = stopResult.errorOrNull(), + ), + ) + } + private fun validate(request: RecordingRequest): AppResult { val invalidReason = when { request.sources.isEmpty() -> "Recording request has no sources" @@ -147,7 +205,13 @@ class RecordingEngine( if (request.durationMillis <= 0L) return recording.durationStop = scope.launch { waitFor(request.durationMillis) - stop(RecordingStopReason.DURATION_EXPIRED) + if (activeRecording === recording) { + recording.durationStop = null + stopRecording( + recording = recording, + context = RecordingStopContext(RecordingStopReason.DURATION_EXPIRED), + ) + } } } @@ -164,11 +228,15 @@ class RecordingEngine( private class ActiveRecording( val request: RecordingRequest, val startedSources: MutableList = mutableListOf(), + val sourceStartJobs: MutableMap = mutableMapOf(), var durationStop: Job? = null, var failureMonitor: Job? = null, ) + private data class FailedSource(val source: RecordingSource, val failure: AppError) + private companion object { const val EVENT_BUFFER_SIZE = 16 + const val SOURCE_STOP_TIMEOUT_MILLIS = 5_000L } } 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 9addf67..7ac701f 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 @@ -1,7 +1,7 @@ package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult @JvmInline value class RecordingSessionId(val value: String) { @@ -66,7 +66,12 @@ data class RecordingStopContext(val reason: RecordingStopReason, val failures: L sealed interface RecordingEvent { val sessionId: RecordingSessionId - data class RecordingStarted(override val sessionId: RecordingSessionId) : RecordingEvent + data class SourceFailed( + override val sessionId: RecordingSessionId, + val sourceType: RecordingSourceType, + val failure: AppError, + val stopFailure: AppError? = null, + ) : RecordingEvent data class RecordingStopped( override val sessionId: RecordingSessionId, 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 9a5ee5e..11f84c7 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 @@ -1,7 +1,7 @@ package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt index 59efbf8..2effa57 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt @@ -1,11 +1,12 @@ package com.tomasrepcik.sensorbox.recording.session -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow + class RecordingSessionStore { private val mutableState = MutableStateFlow(RecordingSessionState.Idle) private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) 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 cf27d67..d341d23 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 @@ -1,14 +1,18 @@ package com.tomasrepcik.sensorbox.recording -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -31,9 +35,14 @@ class RecordingEngineTest { fun `Given unordered sources When recording starts Then sources start in stable order`() = runTest { // Given val calls = mutableListOf() - val engine = engine( - FakeSource(RecordingSourceType.GPS, calls), - FakeSource(RecordingSourceType.SENSOR, calls), + val engine = RecordingEngine( + sources = listOf( + FakeSource(RecordingSourceType.GPS, calls), + FakeSource(RecordingSourceType.SENSOR, calls), + ), + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + waitFor = { }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) val request = request( RecordingSourceSpec.Gps(10, 20), @@ -45,11 +54,13 @@ class RecordingEngineTest { // Then assertTrue(result.isSuccess) + assertTrue(calls.isEmpty()) + runCurrent() assertEquals(listOf("start:SENSOR", "start:GPS"), calls) } @Test - fun `Given a source start fails When recording starts Then attempted sources stop in reverse order`() = runTest { + fun `Given one source start fails When recording starts Then other source keeps recording`() = runTest { // Given val calls = mutableListOf() val engine = engine( @@ -60,16 +71,113 @@ class RecordingEngineTest { RecordingSourceSpec.Sensors(setOf(1), 0), RecordingSourceSpec.Gps(10, 20), ) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() // When val result = engine.start(request) + val event = failed.await() as RecordingEvent.SourceFailed // Then - assertTrue(result.isFailure) + assertTrue(result.isSuccess) + assertEquals(RecordingSourceType.GPS, event.sourceType) + assertEquals( + listOf("start:SENSOR", "start:GPS", "stop:GPS:SOURCE_FAILURE"), + calls, + ) + engine.stop(RecordingStopReason.USER_REQUEST) + } + + @Test + fun `Given source start never returns When recording starts Then next source still launches`() = runTest { + // Given + val calls = mutableListOf() + val engine = engine( + FakeSource(RecordingSourceType.SENSOR, calls, hangOnStart = true), + FakeSource(RecordingSourceType.GPS, calls), + ) + val request = request( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + // When + val result = engine.start(request) + + // Then + assertTrue(result.isSuccess) + assertEquals( + listOf("start:SENSOR", "start:GPS"), + calls, + ) + assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isSuccess) + } + + @Test + fun `Given source cleanup never returns When start fails Then failure is still published`() = runTest { + // Given + val calls = mutableListOf() + val engine = RecordingEngine( + sources = listOf( + FakeSource( + type = RecordingSourceType.SENSOR, + calls = calls, + startFails = true, + hangOnStop = true, + ), + ), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), + ) + val request = request(RecordingSourceSpec.Sensors(setOf(1), 0)) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() + + // When + val result = engine.start(request) + runCurrent() + advanceTimeBy(5_000L) + runCurrent() + val event = failed.await() as RecordingEvent.SourceFailed + + // Then + assertTrue(result.isSuccess) assertEquals( - listOf("stop:GPS:SOURCE_FAILURE", "stop:SENSOR:SOURCE_FAILURE"), - calls.takeLast(2), + listOf("start:SENSOR", "stop:SENSOR:SOURCE_FAILURE"), + calls, ) + assertEquals("Stop SENSOR recording source", event.stopFailure?.operation) + engine.stop(RecordingStopReason.USER_REQUEST) + } + + @Test + fun `Given measurement metadata start fails When recording starts Then sensor still launches`() = runTest { + // Given + val calls = mutableListOf() + val engine = engine( + FakeSource(RecordingSourceType.SESSION_METADATA, calls, startFails = true), + FakeSource(RecordingSourceType.SENSOR, calls), + ) + val request = request( + RecordingSourceSpec.SessionMetadata, + RecordingSourceSpec.Sensors(setOf(1), 0), + ) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() + + // When + val result = engine.start(request) + val event = failed.await() as RecordingEvent.SourceFailed + + // Then + assertTrue(result.isSuccess) + assertEquals(RecordingSourceType.SESSION_METADATA, event.sourceType) + assertEquals( + listOf("start:SESSION_METADATA", "stop:SESSION_METADATA:SOURCE_FAILURE", "start:SENSOR"), + calls, + ) + engine.stop(RecordingStopReason.USER_REQUEST) } @Test @@ -96,7 +204,7 @@ class RecordingEngineTest { } @Test - fun `Given runtime and stop failures When session stops Then session receives every failure`() = runTest { + fun `Given runtime and stop failures When a source fails Then both failures are published`() = runTest { // Given val calls = mutableListOf() val session = FakeSource(RecordingSourceType.SESSION_METADATA, calls) @@ -105,18 +213,23 @@ class RecordingEngineTest { sources = listOf(session, sensor), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) engine.start(request(RecordingSourceSpec.SessionMetadata, RecordingSourceSpec.Sensors(setOf(1), 0))) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() // When sensor.reportFailure() runCurrent() // Then + val event = failed.await() as RecordingEvent.SourceFailed assertEquals( listOf("Record SENSOR", "Stop SENSOR"), - session.stopContext?.failures?.map(AppError::operation), + listOf(event.failure.operation, event.stopFailure?.operation), ) + assertEquals(null, session.stopContext) } @Test @@ -141,9 +254,10 @@ class RecordingEngineTest { // Given val calls = mutableListOf() val engine = RecordingEngine( - sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls)), + sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls, yieldOnStop = true)), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) val request = request(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) @@ -157,16 +271,18 @@ class RecordingEngineTest { } @Test - fun `Given a running source When it reports failure Then the recording stops`() = runTest { + fun `Given one running source fails When another source remains Then recording continues`() = runTest { // Given val calls = mutableListOf() + val metadata = FakeSource(RecordingSourceType.SESSION_METADATA, calls) val source = FakeSource(RecordingSourceType.SENSOR, calls) val engine = RecordingEngine( - sources = listOf(source), + sources = listOf(metadata, source), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) - val request = request(RecordingSourceSpec.Sensors(setOf(1), 0)) + val request = request(RecordingSourceSpec.SessionMetadata, RecordingSourceSpec.Sensors(setOf(1), 0)) engine.start(request) // When @@ -175,13 +291,47 @@ class RecordingEngineTest { // Then assertEquals(1, calls.count { it == "stop:SENSOR:SOURCE_FAILURE" }) - assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isFailure) + assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isSuccess) + assertEquals(1, calls.count { it == "stop:SESSION_METADATA:USER_REQUEST" }) + assertEquals(0, calls.count { it == "stop:SENSOR:USER_REQUEST" }) + } + + @Test + fun `Given every running source fails When recording stops Then metadata only session remains valid`() = runTest { + // Given + val calls = mutableListOf() + val sensor = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls) + val engine = RecordingEngine( + sources = listOf(sensor, gps), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), + ) + engine.start( + request( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ), + ) + + // When + sensor.reportFailure() + gps.reportFailure() + runCurrent() + val result = engine.stop(RecordingStopReason.USER_REQUEST) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, calls.count { it == "stop:SENSOR:SOURCE_FAILURE" }) + assertEquals(1, calls.count { it == "stop:GPS:SOURCE_FAILURE" }) } private fun engine(vararg sources: RecordingSource) = RecordingEngine( sources = sources.toList(), scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), waitFor = { }, + sourceStartDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, ) private fun request(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingRequest( @@ -196,6 +346,9 @@ private class FakeSource( private val calls: MutableList, private val startFails: Boolean = false, private val stopFails: Boolean = false, + private val hangOnStart: Boolean = false, + private val hangOnStop: Boolean = false, + private val yieldOnStop: Boolean = false, ) : RecordingSource { private val mutableFailures = MutableSharedFlow(replay = 1) override val failures: Flow = mutableFailures @@ -204,12 +357,15 @@ private class FakeSource( override suspend fun start(spec: RecordingSourceSpec): AppResult { calls += "start:$type" + if (hangOnStart) awaitCancellation() return if (startFails) failure("Start $type") else AppResult.success(Unit) } override suspend fun stop(context: RecordingStopContext): AppResult { + if (yieldOnStop) yield() stopContext = context calls += "stop:$type:${context.reason}" + if (hangOnStop) awaitCancellation() return if (stopFails) failure("Stop $type") else AppResult.success(Unit) } diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index 1eff022..a24d6d0 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.coroutines.test) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) } hilt { diff --git a/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt b/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt new file mode 100644 index 0000000..2c23ecf --- /dev/null +++ b/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt @@ -0,0 +1,115 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer +import com.google.android.gms.location.ActivityRecognitionResult +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionEvent +import com.google.android.gms.location.ActivityTransitionResult +import com.google.android.gms.location.DetectedActivity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class ActivityRecognitionReceiverTest { + private var registration: ActivityRecognitionCallbackRegistration? = null + + @After + fun unregisterCallbacks() { + registration?.let(ActivityRecognitionCallbacks::unregister) + registration = null + } + + @Test + fun givenGoogleActivityResultWhenPendingIntentIsSentThenConfidenceCallbackReceivesIt() { + // Given + val context = ApplicationProvider.getApplicationContext() + val callbackReceived = CountDownLatch(1) + var update: ActivityUpdate? = null + registration = ActivityRecognitionCallbacks.register( + onUpdate = { received -> + update = received + callbackReceived.countDown() + }, + onTransitions = {}, + ) + val pendingIntent = createActivityRecognitionPendingIntent( + context = context, + requestCode = UPDATE_TEST_REQUEST_CODE, + action = ACTIVITY_UPDATE_ACTION, + ) + val result = ActivityRecognitionResult( + listOf(DetectedActivity(DetectedActivity.WALKING, 83)), + 1L, + 123L, + ) + val resultIntent = Intent().putExtra(ACTIVITY_RESULT_EXTRA, result) + + // When + pendingIntent.send(context, 0, resultIntent) + + // Then + assertTrue(callbackReceived.await(2, TimeUnit.SECONDS)) + assertEquals( + ActivityUpdate(123L, listOf(0, 0, 83, 0, 0, 0, 0, 0)), + update, + ) + pendingIntent.cancel() + } + + @Test + fun givenGoogleTransitionResultWhenReceiverHandlesItThenTransitionCallbackReceivesIt() { + // Given + var transitions: List? = null + registration = ActivityRecognitionCallbacks.register( + onUpdate = {}, + onTransitions = { received -> transitions = received }, + ) + val result = ActivityTransitionResult( + listOf( + ActivityTransitionEvent( + DetectedActivity.WALKING, + ActivityTransition.ACTIVITY_TRANSITION_ENTER, + 456L, + ), + ), + ) + val resultIntent = Intent(ACTIVITY_TRANSITION_ACTION) + SafeParcelableSerializer.serializeToIntentExtra( + result, + resultIntent, + ACTIVITY_TRANSITION_RESULT_EXTRA, + ) + + // When + ActivityRecognitionReceiver().onReceive(null, resultIntent) + + // Then + assertEquals( + listOf( + ActivityTransitionSample( + elapsedRealtimeNanos = 456L, + activityType = DetectedActivity.WALKING, + transitionType = ActivityTransition.ACTIVITY_TRANSITION_ENTER, + ), + ), + transitions, + ) + } + + private companion object { + const val UPDATE_TEST_REQUEST_CODE = 4_571 + const val ACTIVITY_RESULT_EXTRA = + "com.google.android.location.internal.EXTRA_ACTIVITY_RESULT" + const val ACTIVITY_TRANSITION_RESULT_EXTRA = + "com.google.android.location.internal.EXTRA_ACTIVITY_TRANSITION_RESULT" + } +} diff --git a/sensorservices/src/main/AndroidManifest.xml b/sensorservices/src/main/AndroidManifest.xml index c291b4e..1f6ddfe 100644 --- a/sensorservices/src/main/AndroidManifest.xml +++ b/sensorservices/src/main/AndroidManifest.xml @@ -5,18 +5,27 @@ - + + + + + android:exported="false"> + + diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt similarity index 90% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt index 5377fdd..f7ce948 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.services +package com.tomasrepcik.sensorbox.recordinghost.notification import android.app.Notification import android.app.NotificationChannel @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import com.tomasrepcik.sensorbox.sensorservices.R object Notify { @@ -30,6 +31,9 @@ object Notify { .setContentIntent(createOpenAppAction(context)) .addAction(R.drawable.ic_stop, context.getString(R.string.text_stop), stopAction) .build() + .apply { + flags = flags or Notification.FLAG_NO_CLEAR + } } private fun createOpenAppAction(context: Context): PendingIntent? { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt similarity index 92% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt index c20e461..3042dc1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.intent +package com.tomasrepcik.sensorbox.recordinghost.request import android.content.Context import android.content.Intent -import com.tomasrepcik.sensorbox.core.time.ClockFormats +import com.tomasrepcik.sensorbox.core.format.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt similarity index 95% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt index c8fb454..42c02d7 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.sensorservices.intent +package com.tomasrepcik.sensorbox.recordinghost.request import android.content.Intent import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import java.util.UUID data class RecordingRequest( diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt similarity index 74% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt index 2c8a024..83b4ca8 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt @@ -1,31 +1,34 @@ -package com.tomasrepcik.sensorbox.sensorservices.services +package com.tomasrepcik.sensorbox.recordinghost.session +import android.annotation.SuppressLint 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.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE 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.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.notification.Notify +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds internal class RecordingHostResources( private val service: Service, private val scope: CoroutineScope, private val requestStop: (RecordingStopReason) -> Unit, private val playAlarm: () -> Unit, -) { +) : RecordingHostEnvironment { private var wakeLock: PowerManager.WakeLock? = null private var batteryReceiverRegistered = false private var alarmJobs: List = emptyList() @@ -36,32 +39,36 @@ internal class RecordingHostResources( } } - fun start(request: RecordingRequest) { + override fun start(request: RecordingRequest) { promoteToForeground(request) if (request.requiresWakeLock) acquireWakeLock() if (request.stopOnLowBattery) registerLowBatteryReceiver() } - fun scheduleAlarms(offsetsSeconds: List) { + override fun scheduleAlarms(offsetsSeconds: List) { alarmJobs = offsetsSeconds.distinct().sorted().map { seconds -> scope.launch { - delay(seconds * 1_000L) + delay((seconds * 1_000L).milliseconds) playAlarm() } } } - fun release(): AppResult { + override fun release(): AppResult { alarmJobs.forEach(Job::cancel) alarmJobs = emptyList() return listOf(releaseWakeLock(), unregisterLowBatteryReceiver()) .combineAppResults(AppErrorCode.RECORDING, "Release recording host resources") } - fun removeNotification() { + override fun removeNotification() { service.stopForeground(Service.STOP_FOREGROUND_REMOVE) } + override fun stopService() { + service.stopSelf() + } + private fun promoteToForeground(request: RecordingRequest) { val notification = Notify.createRecordingNotification(service) if (Build.VERSION.SDK_INT >= 34) { @@ -71,13 +78,16 @@ internal class RecordingHostResources( } } + @SuppressLint("InlinedApi") private fun foregroundTypes(request: RecordingRequest): Int = if (request.includesGps) { - FOREGROUND_SERVICE_TYPE_HEALTH or FOREGROUND_SERVICE_TYPE_LOCATION + FOREGROUND_SERVICE_TYPE_SPECIAL_USE or FOREGROUND_SERVICE_TYPE_LOCATION } else { - FOREGROUND_SERVICE_TYPE_HEALTH + FOREGROUND_SERVICE_TYPE_SPECIAL_USE } + @SuppressLint("WakelockTimeout") private fun acquireWakeLock() { + // Recordings may be indefinite. The recording host releases this lock when its foreground session ends. val powerManager = service.getSystemService(PowerManager::class.java) wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt new file mode 100644 index 0000000..d1d3764 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt @@ -0,0 +1,173 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.recording.RecordingEvent +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +internal interface RecordingSessionExecution { + val events: SharedFlow + + suspend fun start(): AppResult + + suspend fun stop(reason: RecordingStopReason): AppResult + + fun annotate(timestampMillis: Long, text: String): AppResult + + fun playAlarm(): AppResult +} + +internal interface RecordingHostEnvironment { + fun start(request: RecordingRequest) + + fun scheduleAlarms(offsetsSeconds: List) + + fun release(): AppResult + + fun removeNotification() + + fun stopService() +} + +internal class RecordingHostSession( + private val request: RecordingRequest, + private val execution: RecordingSessionExecution, + private val environment: RecordingHostEnvironment, + private val sessionStore: RecordingSessionStore, + private val diagnosticLogger: DiagnosticLogger, + private val scope: CoroutineScope, + private val elapsedRealtimeMillis: () -> Long, +) { + private var eventJob: Job? = null + private var isFinishing = false + + suspend fun start(): AppResult { + val hostStart = appResult(AppErrorCode.RECORDING, "Start recording foreground host") { + environment.start(request) + } + if (hostStart.isFailure) { + finish(RecordingSessionStopReason.SOURCE_FAILURE, hostStart) + return hostStart + } + + observeExecution() + val result = execution.start() + if (result.isFailure) { + finish(RecordingSessionStopReason.SOURCE_FAILURE, result) + } else { + markRecordingAsRunning() + } + return result + } + + fun annotate(timestampMillis: Long, text: String): AppResult = execution.annotate(timestampMillis, text) + + fun requestStop(reason: RecordingStopReason) { + if (isFinishing) return + sessionStore.markStopping() + scope.launch { execution.stop(reason) } + } + + fun destroy() { + if (isFinishing) { + eventJob?.cancel() + return + } + + isFinishing = true + val executionResult = runBlocking(Dispatchers.IO) { + execution.stop(RecordingStopReason.PLATFORM_DESTROYED) + } + val environmentResult = environment.release() + sessionStore.markIdle() + sessionStore.publishStopped( + request.sessionId, + RecordingSessionStopReason.SERVICE_DESTROYED, + listOf(executionResult, environmentResult).combineAppResults( + AppErrorCode.RECORDING, + "Destroy recording foreground host", + ), + ) + eventJob?.cancel() + } + + private fun observeExecution() { + eventJob?.cancel() + eventJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + execution.events.collect { event -> + when (event) { + is RecordingEvent.SourceFailed -> recordSourceFailure(event) + is RecordingEvent.RecordingStopped -> finish(event.reason.toSessionReason(), event.result) + } + } + } + } + + private fun recordSourceFailure(event: RecordingEvent.SourceFailed) { + diagnosticLogger.record(event.failure.toDiagnosticEvent()) + event.stopFailure?.let { diagnosticLogger.record(it.toDiagnosticEvent()) } + } + + private fun markRecordingAsRunning() { + sessionStore.markRunning( + RecordingSessionState.Running( + sessionId = request.sessionId, + folderName = request.folderName, + startedAtElapsedRealtime = elapsedRealtimeMillis(), + sensorIds = request.sensorIds.toList(), + includesGps = request.includesGps, + ), + ) + environment.scheduleAlarms(request.alarmOffsetsSeconds) + } + + private suspend fun finish(reason: RecordingSessionStopReason, executionResult: AppResult) { + if (isFinishing) return + isFinishing = true + + val hostResult = finishHost() + val result = listOf(executionResult, hostResult) + .combineAppResults(AppErrorCode.RECORDING, "Finish recording foreground host") + sessionStore.publishStopped(request.sessionId, reason, result) + eventJob?.cancel() + eventJob = null + } + + private fun finishHost(): AppResult { + val results = mutableListOf>() + results += environment.release() + results += appResult(AppErrorCode.RECORDING, "Publish idle recording state") { + sessionStore.markIdle() + } + results += appResult(AppErrorCode.RECORDING, "Remove recording notification") { + environment.removeNotification() + } + results += appResult(AppErrorCode.RECORDING, "Stop recording service instance") { + environment.stopService() + } + return results.combineAppResults(AppErrorCode.RECORDING, "Finish recording host resources") + } + + private fun RecordingStopReason.toSessionReason(): RecordingSessionStopReason = when (this) { + RecordingStopReason.USER_REQUEST -> RecordingSessionStopReason.USER_REQUEST + RecordingStopReason.DURATION_EXPIRED -> RecordingSessionStopReason.DURATION_EXPIRED + RecordingStopReason.LOW_BATTERY -> RecordingSessionStopReason.LOW_BATTERY + RecordingStopReason.SOURCE_FAILURE -> RecordingSessionStopReason.SOURCE_FAILURE + RecordingStopReason.PLATFORM_DESTROYED -> RecordingSessionStopReason.SERVICE_DESTROYED + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt new file mode 100644 index 0000000..8831f9c --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import android.app.Service +import android.os.SystemClock +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.session.ServiceController +import com.tomasrepcik.sensorbox.recordinghost.storage.StorageHandler +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +internal class RecordingHostSessionFactory @Inject constructor( + private val storage: StorageHandler, + private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, + private val sessionStore: RecordingSessionStore, +) { + fun create(service: Service, request: RecordingRequest, scope: CoroutineScope): RecordingHostSession { + val execution = ServiceController( + context = service, + request = request, + scope = scope, + storage = storage, + diagnosticLogger = diagnosticLogger, + clock = clock, + ) + lateinit var session: RecordingHostSession + val environment = RecordingHostResources( + service = service, + scope = scope, + requestStop = { reason -> session.requestStop(reason) }, + playAlarm = { execution.playAlarm() }, + ) + session = RecordingHostSession( + request = request, + execution = execution, + environment = environment, + sessionStore = sessionStore, + diagnosticLogger = diagnosticLogger, + scope = scope, + elapsedRealtimeMillis = SystemClock::elapsedRealtime, + ) + return session + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt new file mode 100644 index 0000000..a9fc247 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt @@ -0,0 +1,100 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class RecordingService : Service() { + @Inject + lateinit var sessionStore: RecordingSessionStore + + @Inject + internal lateinit var sessionFactory: RecordingHostSessionFactory + + @Inject + lateinit var epochClock: EpochClock + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var hostSession: RecordingHostSession? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP_RECORDING -> hostSession?.requestStop(RecordingStopReason.USER_REQUEST) + + ACTION_ANNOTATE -> hostSession?.annotate( + intent.getLongExtra(ANNOTATION_TIME, epochClock.nowMillis()), + intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), + ) + + else -> intent?.takeIf { hostSession == null }?.let(::startRecordingHost) + } + return START_NOT_STICKY + } + + private fun startRecordingHost(intent: Intent) { + appResult(AppErrorCode.RECORDING, "Start recording foreground host") { + val request = RecordingRequest.from(intent) + require(request.sessionId.isNotBlank()) { "Recording session ID is missing" } + sessionFactory.create(this, request, serviceScope).also { session -> + hostSession = session + serviceScope.launch { session.start() } + } + }.onFailure { error -> + sessionStore.markIdle() + sessionStore.publishStopped( + sessionId = "", + reason = RecordingSessionStopReason.SOURCE_FAILURE, + result = AppResult.failure(error), + ) + stopSelf() + } + } + + override fun onDestroy() { + hostSession?.destroy() + hostSession = null + serviceScope.cancel() + super.onDestroy() + } + + companion object { + const val SESSION_ID = "SESSION_ID" + const val FOLDER_NAME = "FOLDER_NAME" + const val INTERNAL_STORAGE = "INTERNAL_STORAGE" + const val ANDROID_SENSORS = "ANDROID_SENSORS" + const val ANDROID_SENSORS_SPEED = "ANDROID_SENSORS_SPEED" + const val GPS = "GPS_MEASURE" + const val STOP_ON_LOW_BATTERY = "STOP_ON_LOW_BATTERY" + 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 DURATION_MILLIS = "DURATION_MILLIS" + const val NOTES = "NOTES" + const val ALARM_OFFSETS_SECONDS = "ALARM_OFFSETS_SECONDS" + const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" + const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" + const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" + const val ACTION_ANNOTATE = "com.tomasrepcik.sensorbox.action.ANNOTATE_MEASUREMENT" + const val ANNOTATION_TIME = "ANNOTATION_TIME" + const val ANNOTATION_TEXT = "ANNOTATION_TEXT" + const val ACTION_STOP_RECORDING = "com.tomasrepcik.sensorbox.action.STOP_RECORDING" + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt similarity index 60% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt index 4a28f95..c3d7476 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt @@ -1,16 +1,17 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.session import android.content.Context -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingEngine import com.tomasrepcik.sensorbox.recording.RecordingEvent import com.tomasrepcik.sensorbox.recording.RecordingSessionId import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.AndroidRecordingSources +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import com.tomasrepcik.sensorbox.recording.RecordingRequest as EngineRecordingRequest @@ -19,10 +20,10 @@ internal class ServiceController( context: Context, private val request: RecordingRequest, scope: CoroutineScope, - storage: MeasurementStorage, + private val storage: MeasurementStorage, diagnosticLogger: DiagnosticLogger, clock: EpochClock, -) { +) : RecordingSessionExecution { private val androidSources = AndroidRecordingSources(context, request, storage, diagnosticLogger, clock) private val sessionId = RecordingSessionId(request.sessionId) private val engine = RecordingEngine( @@ -30,15 +31,24 @@ internal class ServiceController( scope = scope, ) - val events: SharedFlow = engine.events + override val events: SharedFlow = engine.events - suspend fun start(): AppResult = engine.start(request.toEngineRequest(sessionId)) + override suspend fun start(): AppResult { + val directory = storage.createMeasurementDirectory( + request.folderName, + request.useInternalStorage, + ) + if (directory is AppResult.Failure) return directory + + return engine.start(request.toEngineRequest(sessionId)) + } - suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(reason) + override suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(reason) - fun annotate(timestampMillis: Long, text: String): AppResult = androidSources.annotate(timestampMillis, text) + override fun annotate(timestampMillis: Long, text: String): AppResult = + androidSources.annotate(timestampMillis, text) - fun playAlarm(): AppResult = androidSources.playAlarm() + override fun playAlarm(): AppResult = androidSources.playAlarm() private fun RecordingRequest.toEngineRequest(sessionId: RecordingSessionId): EngineRecordingRequest { val specs = buildList { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt similarity index 58% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt index 351082b..31c6314 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt @@ -1,19 +1,24 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources import android.content.Context 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.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingSource -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.AndroidActivityRecognitionPlatform -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GpsRecording -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorRecording -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.AndroidActivityRecognitionPlatform +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.sessionmetadata.SessionMetadataRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class AndroidRecordingSources( context: Context, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt similarity index 50% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt index 3cc809e..e7855e4 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSourceType internal fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt new file mode 100644 index 0000000..d56001f --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt @@ -0,0 +1,175 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityRecognitionClient +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionRequest +import com.google.android.gms.location.DetectedActivity +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import kotlinx.coroutines.tasks.await + +internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) + +internal data class ActivityTransitionSample( + val elapsedRealtimeNanos: Long, + val activityType: Int, + val transitionType: Int, +) + +internal interface ActivityRecognitionPlatform { + suspend fun start( + periodSeconds: Int, + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult + + suspend fun stop(): AppResult +} + +internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { + private var client: ActivityRecognitionClient? = null + private var updatesPendingIntent: PendingIntent? = null + private var transitionsPendingIntent: PendingIntent? = null + private var callbacks: ActivityRecognitionCallbackRegistration? = null + + @SuppressLint("MissingPermission") + override suspend fun start( + periodSeconds: Int, + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult { + if (!hasPermission()) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) + } + val prepared = prepareResources(onUpdate, onTransitions) + if (prepared.isFailure) return prepared + return suspendAppResult(AppErrorCode.RECORDING, "Start activity recognition") { + val activeClient = checkNotNull(client) { "Activity recognition client is missing" } + val updates = checkNotNull(updatesPendingIntent) { "Activity update request is missing" } + val transitions = checkNotNull(transitionsPendingIntent) { "Activity transition request is missing" } + activeClient.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, updates).await() + activeClient.requestActivityTransitionUpdates( + ActivityTransitionRequest(activityTransitions), + transitions, + ).await() + } + } + + private fun prepareResources( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult = appResult(AppErrorCode.RECORDING, "Initialize activity recognition resources") { + client = ActivityRecognition.getClient(context) + updatesPendingIntent = createActivityRecognitionPendingIntent( + context, + UPDATES_REQUEST_CODE, + ACTIVITY_UPDATE_ACTION, + ) + transitionsPendingIntent = createActivityRecognitionPendingIntent( + context, + TRANSITIONS_REQUEST_CODE, + ACTIVITY_TRANSITION_ACTION, + ) + callbacks = ActivityRecognitionCallbacks.register(onUpdate, onTransitions) + } + + @SuppressLint("MissingPermission") + override suspend fun stop(): AppResult { + val results = mutableListOf>() + updatesPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity updates") { + client?.removeActivityUpdates(pendingIntent)?.await() + } + } + transitionsPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity transitions") { + client?.removeActivityTransitionUpdates(pendingIntent)?.await() + } + } + results += appResult(AppErrorCode.RECORDING, "Release activity recognition resources") { + callbacks?.let(ActivityRecognitionCallbacks::unregister) + client = null + updatesPendingIntent = null + transitionsPendingIntent = null + callbacks = null + } + return results.combineAppResults(AppErrorCode.RECORDING, "Pause activity recognition") + } + + private fun hasPermission(): Boolean { + val permission = if (Build.VERSION.SDK_INT >= 29) { + Manifest.permission.ACTIVITY_RECOGNITION + } else { + LEGACY_ACTIVITY_RECOGNITION_PERMISSION + } + return ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + } + + private companion object { + const val UPDATES_REQUEST_CODE = 457 + const val TRANSITIONS_REQUEST_CODE = 1_654 + } +} + +internal const val ACTIVITY_UPDATE_ACTION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_UPDATE" +internal const val ACTIVITY_TRANSITION_ACTION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" +internal const val LEGACY_ACTIVITY_RECOGNITION_PERMISSION = + "com.google.android.gms.permission.ACTIVITY_RECOGNITION" + +internal val ACTIVITY_CONFIDENCE_TYPES = intArrayOf( + DetectedActivity.STILL, + DetectedActivity.ON_FOOT, + DetectedActivity.WALKING, + DetectedActivity.RUNNING, + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_BICYCLE, + DetectedActivity.UNKNOWN, + DetectedActivity.TILTING, +) + +internal val ACTIVITY_TRANSITION_TYPES = intArrayOf( + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_FOOT, + DetectedActivity.RUNNING, + DetectedActivity.WALKING, + DetectedActivity.ON_BICYCLE, + DetectedActivity.STILL, +) + +private val activityTransitions = ACTIVITY_TRANSITION_TYPES.flatMap { activity -> + listOf( + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) + .build(), + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) + .build(), + ) +} + +internal fun createActivityRecognitionPendingIntent( + context: Context, + requestCode: Int, + action: String, +): PendingIntent = PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, ActivityRecognitionReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, +) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt new file mode 100644 index 0000000..507e3aa --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt @@ -0,0 +1,68 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.google.android.gms.location.ActivityRecognitionResult +import com.google.android.gms.location.ActivityTransitionResult + +class ActivityRecognitionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let(ActivityRecognitionCallbacks::receive) + } +} + +internal class ActivityRecognitionCallbackRegistration( + val onUpdate: (ActivityUpdate) -> Unit, + val onTransitions: (List) -> Unit, +) + +internal object ActivityRecognitionCallbacks { + @Volatile + private var active: ActivityRecognitionCallbackRegistration? = null + + fun register( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): ActivityRecognitionCallbackRegistration = + ActivityRecognitionCallbackRegistration(onUpdate, onTransitions).also { registration -> + active = registration + } + + fun unregister(registration: ActivityRecognitionCallbackRegistration) { + synchronized(this) { + if (active === registration) active = null + } + } + + fun receive(intent: Intent) { + val registration = active ?: return + when (intent.action) { + ACTIVITY_UPDATE_ACTION -> receiveUpdate(intent, registration) + ACTIVITY_TRANSITION_ACTION -> receiveTransitions(intent, registration) + } + } + + private fun receiveUpdate(intent: Intent, registration: ActivityRecognitionCallbackRegistration) { + val result = ActivityRecognitionResult.extractResult(intent) ?: return + registration.onUpdate( + ActivityUpdate( + elapsedRealtimeMillis = result.elapsedRealtimeMillis, + confidences = ACTIVITY_CONFIDENCE_TYPES.map(result::getActivityConfidence), + ), + ) + } + + private fun receiveTransitions(intent: Intent, registration: ActivityRecognitionCallbackRegistration) { + val result = ActivityTransitionResult.extractResult(intent) ?: return + registration.onTransitions( + result.transitionEvents.map { transition -> + ActivityTransitionSample( + elapsedRealtimeNanos = transition.elapsedRealTimeNanos, + activityType = transition.activityType, + transitionType = transition.transitionType, + ) + }, + ) + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt similarity index 90% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt index 5aea36c..67689d1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt @@ -1,12 +1,12 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -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 -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt similarity index 69% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt index 2becec0..4e36d28 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt @@ -1,14 +1,15 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionPlatform -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecognitionPlatform +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecognitionRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class ActivityRecordingSource( private val request: RecordingRequest, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt similarity index 93% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt index fa5a1ca..312e428 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.sources.gps import android.annotation.SuppressLint import android.location.Location @@ -11,10 +11,10 @@ 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.Priority -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 com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap @SuppressLint("MissingPermission") class GPSHandler(private val locationClient: FusedLocationProviderClient? = null) : LocationCallback() { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt similarity index 85% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt index 83d783e..3e7c315 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.gps import android.location.Location -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 -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt similarity index 75% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt index f5bb233..ceec0dc 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.gps -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GpsRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec internal class GpsRecordingSource(private val request: RecordingRequest, private val recording: GpsRecording) : RecordingSource { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt similarity index 89% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt index 05c9322..fd6e42a 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener -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.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt similarity index 82% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt index 6294e1b..67d08fe 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt @@ -1,18 +1,18 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.SensorManager -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.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats -import com.tomasrepcik.sensorbox.sensorservices.types.SensorHolder -import com.tomasrepcik.sensorbox.sensorservices.types.SensorSpec +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorHolder +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorSpec +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt similarity index 75% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt index 42bd070..e7ab7b3 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording internal class SensorRecordingSource(private val request: RecordingRequest, private val recording: SensorRecording) : RecordingSource { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt similarity index 96% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt index f1150d5..bcfd268 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt similarity index 74% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt index 831109d..1ff1e52 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt @@ -1,25 +1,26 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.sessionmetadata 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.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.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.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.MeasurementMetadataWriter -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementMetadataWriter +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class SessionMetadataRecordingSource( private val request: RecordingRequest, - private val storage: MeasurementStorage, + storage: MeasurementStorage, clock: EpochClock, sensorManager: SensorManager, ) : RecordingSource { @@ -30,8 +31,8 @@ internal class SessionMetadataRecordingSource( override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.SessionMetadata) { - storage.createMeasurementDirectory(request.folderName, request.useInternalStorage) - .onSuccess { metadataWriter.start(request) } + metadataWriter.start(request) + AppResult.success(Unit) } else { invalidSpec(type) } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt similarity index 86% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt index 307c558..705463a 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt @@ -1,17 +1,17 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion import android.hardware.Sensor import android.hardware.SensorManager import android.hardware.TriggerEvent import android.hardware.TriggerEventListener -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 -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt similarity index 71% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt index af200fe..571b10d 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecording internal class SignificantMotionRecordingSource( private val request: RecordingRequest, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt similarity index 90% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt index 15084e9..371cf59 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt @@ -1,20 +1,20 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.storage import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock -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 -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.core.time.ClockFormats +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.core.format.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt similarity index 89% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt index e6acd93..37d8f4f 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.Context -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 com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt similarity index 94% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt index 9b3b57e..680a546 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.di +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.Context import com.tomasrepcik.sensorbox.core.storage.DocumentStorage diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt deleted file mode 100644 index e18086d..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt +++ /dev/null @@ -1,185 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements - -import android.Manifest -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import com.google.android.gms.location.ActivityRecognition -import com.google.android.gms.location.ActivityRecognitionClient -import com.google.android.gms.location.ActivityRecognitionResult -import com.google.android.gms.location.ActivityTransition -import com.google.android.gms.location.ActivityTransitionRequest -import com.google.android.gms.location.ActivityTransitionResult -import com.google.android.gms.location.DetectedActivity -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 -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import kotlinx.coroutines.tasks.await - -internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) - -internal data class ActivityTransitionSample( - val elapsedRealtimeNanos: Long, - val activityType: Int, - val transitionType: Int, -) - -internal interface ActivityRecognitionPlatform { - suspend fun start( - periodSeconds: Int, - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult - - suspend fun stop(): AppResult -} - -internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { - private var client: ActivityRecognitionClient? = null - private var updatesPendingIntent: PendingIntent? = null - private var transitionsPendingIntent: PendingIntent? = null - private var receiverRegistered = false - private var updateCallback: ((ActivityUpdate) -> Unit)? = null - private var transitionCallback: ((List) -> Unit)? = null - - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_UPDATE -> ActivityRecognitionResult.extractResult(intent)?.let { result -> - updateCallback?.invoke( - ActivityUpdate( - elapsedRealtimeMillis = result.elapsedRealtimeMillis, - confidences = ACTIVITIES.map(result::getActivityConfidence), - ), - ) - } - - ACTION_TRANSITION -> ActivityTransitionResult.extractResult(intent)?.let { result -> - transitionCallback?.invoke( - result.transitionEvents.map { transition -> - ActivityTransitionSample( - elapsedRealtimeNanos = transition.elapsedRealTimeNanos, - activityType = transition.activityType, - transitionType = transition.transitionType, - ) - }, - ) - } - } - } - } - - @SuppressLint("MissingPermission") - override suspend fun start( - periodSeconds: Int, - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult { - if (!hasPermission()) { - return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) - } - val prepared = prepareResources(onUpdate, onTransitions) - if (prepared.isFailure) return prepared - return suspendAppResult(AppErrorCode.RECORDING, "Start activity recognition") { - val activeClient = checkNotNull(client) { "Activity recognition client is missing" } - val updates = checkNotNull(updatesPendingIntent) { "Activity update request is missing" } - val transitions = checkNotNull(transitionsPendingIntent) { "Activity transition request is missing" } - activeClient.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, updates).await() - activeClient.requestActivityTransitionUpdates( - ActivityTransitionRequest(ACTIVITY_TRANSITIONS), - transitions, - ).await() - } - } - - private fun prepareResources( - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult = appResult(AppErrorCode.RECORDING, "Initialize activity recognition resources") { - updateCallback = onUpdate - transitionCallback = onTransitions - val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } - ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) - receiverRegistered = true - client = ActivityRecognition.getClient(context) - updatesPendingIntent = PendingIntent.getBroadcast( - context, - UPDATES_REQUEST_CODE, - Intent(ACTION_UPDATE).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - transitionsPendingIntent = PendingIntent.getBroadcast( - context, - TRANSITIONS_REQUEST_CODE, - Intent(ACTION_TRANSITION).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } - - @SuppressLint("MissingPermission") - override suspend fun stop(): AppResult { - val results = mutableListOf>() - updatesPendingIntent?.let { pendingIntent -> - results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity updates") { - client?.removeActivityUpdates(pendingIntent)?.await() - } - } - transitionsPendingIntent?.let { pendingIntent -> - results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity transitions") { - client?.removeActivityTransitionUpdates(pendingIntent)?.await() - } - } - results += appResult(AppErrorCode.RECORDING, "Release activity recognition resources") { - if (receiverRegistered) context.unregisterReceiver(receiver) - receiverRegistered = false - client = null - updatesPendingIntent = null - transitionsPendingIntent = null - updateCallback = null - transitionCallback = null - } - return results.combineAppResults(AppErrorCode.RECORDING, "Pause activity recognition") - } - - private fun hasPermission(): Boolean = Build.VERSION.SDK_INT < 29 || - ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == - PackageManager.PERMISSION_GRANTED - - private companion object { - const val ACTION_UPDATE = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_UPDATE" - const val ACTION_TRANSITION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" - const val UPDATES_REQUEST_CODE = 457 - const val TRANSITIONS_REQUEST_CODE = 1_654 - val ACTIVITIES = intArrayOf( - DetectedActivity.STILL, - DetectedActivity.ON_FOOT, - DetectedActivity.WALKING, - DetectedActivity.RUNNING, - DetectedActivity.IN_VEHICLE, - DetectedActivity.ON_BICYCLE, - DetectedActivity.UNKNOWN, - DetectedActivity.TILTING, - ) - val ACTIVITY_TRANSITIONS = ACTIVITIES.flatMap { activity -> - listOf( - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) - .build(), - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) - .build(), - ) - } - } -} 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 deleted file mode 100644 index 4e2b6b2..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -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, request: RecordingRequest, scope: CoroutineScope) = ServiceController( - context = context, - request = request, - scope = scope, - storage = storage, - diagnosticLogger = diagnosticLogger, - clock = clock, - ) -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt deleted file mode 100644 index 908823e..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.services - -import android.app.Service -import android.content.Intent -import android.os.IBinder -import android.os.SystemClock -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.recording.RecordingEvent -import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceController -import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceControllerFactory -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopReason -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import javax.inject.Inject - -@AndroidEntryPoint -class RecordingService : Service() { - @Inject - lateinit var sessionStore: RecordingSessionStore - - @Inject - internal lateinit var controllerFactory: ServiceControllerFactory - - @Inject - lateinit var epochClock: EpochClock - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var controller: ServiceController? = null - private var activeRequest: RecordingRequest? = null - private var eventJob: Job? = null - private var isFinishing = false - private val hostResources by lazy { - RecordingHostResources( - service = this, - scope = serviceScope, - requestStop = ::requestStop, - playAlarm = { controller?.playAlarm() }, - ) - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP_RECORDING -> requestStop(RecordingStopReason.USER_REQUEST) - - ACTION_ANNOTATE -> controller?.annotate( - intent.getLongExtra(ANNOTATION_TIME, epochClock.nowMillis()), - intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), - ) - - else -> intent?.takeIf { activeRequest == null }?.let(::startRecordingHost) - } - return START_NOT_STICKY - } - - private fun startRecordingHost(intent: Intent) { - appResult(AppErrorCode.RECORDING, "Start recording foreground host") { - val request = RecordingRequest.from(intent) - require(request.sessionId.isNotBlank()) { "Recording session ID is missing" } - activeRequest = request - isFinishing = false - hostResources.start(request) - val serviceController = controllerFactory.create(this, request, serviceScope) - controller = serviceController - observeEngine(serviceController) - serviceScope.launch { - val result = serviceController.start() - if (result.isFailure) { - finishRecording( - RecordingSessionStopReason.SOURCE_FAILURE, - result, - ) - } - } - }.onFailure { error -> - serviceScope.launch { - finishRecording( - RecordingSessionStopReason.SOURCE_FAILURE, - AppResult.failure(error), - ) - } - } - } - - private fun observeEngine(serviceController: ServiceController) { - eventJob?.cancel() - eventJob = serviceScope.launch { - serviceController.events.collect { event -> - when (event) { - is RecordingEvent.RecordingStarted -> onRecordingStarted(event) - - is RecordingEvent.RecordingStopped -> finishRecording( - event.reason.toRecordingSessionReason(), - event.result, - ) - } - } - } - } - - private fun onRecordingStarted(event: RecordingEvent.RecordingStarted) { - val request = activeRequest ?: return - sessionStore.markRunning( - RecordingSessionState.Running( - sessionId = event.sessionId.value, - folderName = request.folderName, - startedAtElapsedRealtime = SystemClock.elapsedRealtime(), - sensorIds = request.sensorIds.toList(), - includesGps = request.includesGps, - ), - ) - hostResources.scheduleAlarms(request.alarmOffsetsSeconds) - } - - private fun requestStop(reason: RecordingStopReason) { - if (isFinishing) return - sessionStore.markStopping() - serviceScope.launch { - controller?.stop(reason) - } - } - - private suspend fun finishRecording(reason: RecordingSessionStopReason, engineResult: AppResult) { - if (isFinishing) return - isFinishing = true - val sessionId = activeRequest?.sessionId.orEmpty() - val hostResult = finishHost() - val result = listOf(engineResult, hostResult) - .combineAppResults(AppErrorCode.RECORDING, "Finish recording foreground host") - sessionStore.publishStopped(sessionId, reason, result) - eventJob?.cancel() - eventJob = null - } - - private fun finishHost(): AppResult { - val results = mutableListOf>() - results += hostResources.release() - activeRequest = null - controller = null - results += appResult(AppErrorCode.RECORDING, "Publish idle recording state") { - sessionStore.markIdle() - } - results += appResult(AppErrorCode.RECORDING, "Remove recording notification") { - hostResources.removeNotification() - } - results += appResult(AppErrorCode.RECORDING, "Stop recording service instance") { - stopSelf() - } - return results.combineAppResults(AppErrorCode.RECORDING, "Finish recording host resources") - } - - override fun onDestroy() { - val request = activeRequest - if (request != null && !isFinishing) { - isFinishing = true - val cleanup = runBlocking(Dispatchers.IO) { - controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) - } - val resources = hostResources.release() - sessionStore.markIdle() - sessionStore.publishStopped( - request.sessionId, - RecordingSessionStopReason.SERVICE_DESTROYED, - listOf(cleanup, resources).combineAppResults( - AppErrorCode.RECORDING, - "Destroy recording foreground host", - ), - ) - } - eventJob?.cancel() - serviceScope.cancel() - super.onDestroy() - } - - private fun RecordingStopReason.toRecordingSessionReason(): RecordingSessionStopReason = when (this) { - RecordingStopReason.USER_REQUEST -> RecordingSessionStopReason.USER_REQUEST - RecordingStopReason.DURATION_EXPIRED -> RecordingSessionStopReason.DURATION_EXPIRED - RecordingStopReason.LOW_BATTERY -> RecordingSessionStopReason.LOW_BATTERY - RecordingStopReason.SOURCE_FAILURE -> RecordingSessionStopReason.SOURCE_FAILURE - RecordingStopReason.PLATFORM_DESTROYED -> RecordingSessionStopReason.SERVICE_DESTROYED - } - - companion object { - const val SESSION_ID = "SESSION_ID" - const val FOLDER_NAME = "FOLDER_NAME" - const val INTERNAL_STORAGE = "INTERNAL_STORAGE" - const val ANDROID_SENSORS = "ANDROID_SENSORS" - const val ANDROID_SENSORS_SPEED = "ANDROID_SENSORS_SPEED" - const val GPS = "GPS_MEASURE" - const val STOP_ON_LOW_BATTERY = "STOP_ON_LOW_BATTERY" - 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 DURATION_MILLIS = "DURATION_MILLIS" - const val NOTES = "NOTES" - const val ALARM_OFFSETS_SECONDS = "ALARM_OFFSETS_SECONDS" - const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" - const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" - const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" - const val ACTION_ANNOTATE = "com.tomasrepcik.sensorbox.action.ANNOTATE_MEASUREMENT" - const val ANNOTATION_TIME = "ANNOTATION_TIME" - const val ANNOTATION_TEXT = "ANNOTATION_TEXT" - const val ACTION_STOP_RECORDING = "com.tomasrepcik.sensorbox.action.STOP_RECORDING" - } -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt deleted file mode 100644 index fa3b3c7..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.session - -sealed interface RecordingSessionState { - data object Idle : RecordingSessionState - - data class Running( - val sessionId: String, - val folderName: String, - val startedAtElapsedRealtime: Long, - val sensorIds: List, - val includesGps: Boolean, - ) : RecordingSessionState - - data object Stopping : RecordingSessionState -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt deleted file mode 100644 index 752b71f..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.session - -import com.tomasrepcik.sensorbox.core.error.AppResult -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class RecordingSessionStore @Inject constructor() { - private val mutableState = MutableStateFlow(RecordingSessionState.Idle) - private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) - val state: StateFlow = mutableState.asStateFlow() - val events: SharedFlow = mutableEvents - - fun markRunning(state: RecordingSessionState.Running) { - mutableState.value = state - } - - fun markStopping() { - mutableState.value = RecordingSessionState.Stopping - } - - fun markIdle() { - mutableState.value = RecordingSessionState.Idle - } - - fun publishStopped(sessionId: String, reason: RecordingSessionStopReason, result: AppResult) { - mutableEvents.tryEmit(RecordingSessionStopped(sessionId, reason, result)) - } - - private companion object { - const val EVENT_BUFFER_SIZE = 16 - } -} - -enum class RecordingSessionStopReason { - USER_REQUEST, - DURATION_EXPIRED, - LOW_BATTERY, - SOURCE_FAILURE, - SERVICE_DESTROYED, -} - -data class RecordingSessionStopped( - val sessionId: String, - val reason: RecordingSessionStopReason, - val result: AppResult, -) diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt new file mode 100644 index 0000000..66297d9 --- /dev/null +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt @@ -0,0 +1,239 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.recording.RecordingEvent +import com.tomasrepcik.sensorbox.recording.RecordingSessionId +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingHostSessionTest { + @Test + fun `Given a recording request When execution starts Then running state and alarms are published`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + + // When + val result = fixture.session.start() + + // Then + assertTrue(result.isSuccess) + val running = fixture.store.state.value as RecordingSessionState.Running + assertEquals("session-123", running.sessionId) + assertEquals(55_000L, running.startedAtElapsedRealtime) + assertEquals(listOf(4, 8), fixture.environment.scheduledAlarms) + } + + @Test + fun `Given execution start failure When session starts Then host finishes and publishes failure`() = runTest { + // Given + val fixture = Fixture(backgroundScope).apply { + execution.startResult = AppResult.failure(AppError(AppErrorCode.RECORDING, "Start fixture execution")) + } + val stopped = async { fixture.store.events.first() } + runCurrent() + + // When + val result = fixture.session.start() + + // Then + assertTrue(result.isFailure) + assertEquals(RecordingSessionStopReason.SOURCE_FAILURE, stopped.await().reason) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(1, fixture.environment.removeNotificationCalls) + assertEquals(1, fixture.environment.stopServiceCalls) + } + + @Test + fun `Given execution stops twice When events arrive Then host cleanup runs once`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + runCurrent() + val stopped = async { fixture.store.events.first() } + runCurrent() + val event = RecordingEvent.RecordingStopped( + sessionId = RecordingSessionId("session-123"), + reason = RecordingStopReason.DURATION_EXPIRED, + result = AppResult.success(Unit), + ) + + // When + fixture.execution.emit(event) + runCurrent() + fixture.execution.emit(event) + runCurrent() + + // Then + assertEquals(RecordingSessionStopReason.DURATION_EXPIRED, stopped.await().reason) + assertTrue(fixture.store.state.value is RecordingSessionState.Idle) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(1, fixture.environment.stopServiceCalls) + } + + @Test + fun `Given a recording source fails When execution continues Then failures are stored in diagnostics`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + val sourceFailure = AppError(AppErrorCode.RECORDING, "Record SENSOR") + val stopFailure = AppError(AppErrorCode.RECORDING, "Stop SENSOR") + + // When + fixture.execution.emit( + RecordingEvent.SourceFailed( + sessionId = RecordingSessionId("session-123"), + sourceType = com.tomasrepcik.sensorbox.recording.RecordingSourceType.SENSOR, + failure = sourceFailure, + stopFailure = stopFailure, + ), + ) + runCurrent() + + // Then + assertEquals(listOf("Record SENSOR", "Stop SENSOR"), fixture.diagnostics.map(DiagnosticEvent::operation)) + assertTrue(fixture.store.state.value !is RecordingSessionState.Idle) + assertEquals(0, fixture.environment.releaseCalls) + } + + @Test + fun `Given execution reports a source failure during start When session starts Then failure is observed`() = + runTest { + // Given + val sourceFailure = AppError(AppErrorCode.RECORDING, "Record SENSOR") + val fixture = Fixture(backgroundScope).apply { + execution.eventDuringStart = RecordingEvent.SourceFailed( + sessionId = RecordingSessionId("session-123"), + sourceType = com.tomasrepcik.sensorbox.recording.RecordingSourceType.SENSOR, + failure = sourceFailure, + stopFailure = null, + ) + } + + // When + val result = fixture.session.start() + runCurrent() + + // Then + assertTrue(result.isSuccess) + assertEquals(listOf("Record SENSOR"), fixture.diagnostics.map(DiagnosticEvent::operation)) + } + + @Test + fun `Given an active host When service is destroyed Then execution and resources are released`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + runCurrent() + val stopped = async { fixture.store.events.first() } + runCurrent() + + // When + fixture.session.destroy() + + // Then + assertEquals(RecordingStopReason.PLATFORM_DESTROYED, fixture.execution.stopReasons.single()) + assertEquals(RecordingSessionStopReason.SERVICE_DESTROYED, stopped.await().reason) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(0, fixture.environment.stopServiceCalls) + } + + private class Fixture(scope: kotlinx.coroutines.CoroutineScope) { + val execution = FakeRecordingSessionExecution() + val environment = FakeRecordingHostEnvironment() + val store = RecordingSessionStore() + val diagnostics = mutableListOf() + val session = RecordingHostSession( + request = request(), + execution = execution, + environment = environment, + sessionStore = store, + diagnosticLogger = DiagnosticLogger(diagnostics::add), + scope = scope, + elapsedRealtimeMillis = { 55_000L }, + ) + + private fun request() = RecordingRequest( + sessionId = "session-123", + folderName = "fixture", + useInternalStorage = true, + sensorIds = setOf(1), + sensorSamplingPeriod = 0, + includesGps = false, + stopOnLowBattery = false, + useWakeLock = false, + gpsIntervalSeconds = 10, + gpsMinDistanceMeters = 20, + alarmOffsetsSeconds = listOf(4, 8), + ) + } +} + +private class FakeRecordingSessionExecution : RecordingSessionExecution { + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 8) + override val events: SharedFlow = mutableEvents + var startResult: AppResult = AppResult.success(Unit) + var eventDuringStart: RecordingEvent? = null + val stopReasons = mutableListOf() + + override suspend fun start(): AppResult { + eventDuringStart?.let(mutableEvents::tryEmit) + return startResult + } + + override suspend fun stop(reason: RecordingStopReason): AppResult { + stopReasons += reason + return AppResult.success(Unit) + } + + override fun annotate(timestampMillis: Long, text: String): AppResult = AppResult.success(Unit) + + override fun playAlarm(): AppResult = AppResult.success(Unit) + + fun emit(event: RecordingEvent) { + mutableEvents.tryEmit(event) + } +} + +private class FakeRecordingHostEnvironment : RecordingHostEnvironment { + var scheduledAlarms: List = emptyList() + var releaseCalls = 0 + var removeNotificationCalls = 0 + var stopServiceCalls = 0 + + override fun start(request: RecordingRequest) = Unit + + override fun scheduleAlarms(offsetsSeconds: List) { + scheduledAlarms = offsetsSeconds + } + + override fun release(): AppResult { + releaseCalls += 1 + return AppResult.success(Unit) + } + + override fun removeNotification() { + removeNotificationCalls += 1 + } + + override fun stopService() { + stopServiceCalls += 1 + } +} diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt similarity index 76% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt index 4cc244d..1cb4472 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt @@ -1,13 +1,15 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources import android.hardware.Sensor -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt new file mode 100644 index 0000000..5ea2e56 --- /dev/null +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import com.google.android.gms.location.DetectedActivity +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ActivityRecognitionPlatformTest { + @Test + fun `Given activity updates When confidence types are requested Then every CSV activity is included`() { + // Given + val expected = intArrayOf( + DetectedActivity.STILL, + DetectedActivity.ON_FOOT, + DetectedActivity.WALKING, + DetectedActivity.RUNNING, + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_BICYCLE, + DetectedActivity.UNKNOWN, + DetectedActivity.TILTING, + ) + + // When + val actual = ACTIVITY_CONFIDENCE_TYPES + + // Then + assertArrayEquals(expected, actual) + } + + @Test + fun `Given transition updates When activity types are requested Then only supported types are included`() { + // Given + val expected = intArrayOf( + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_FOOT, + DetectedActivity.RUNNING, + DetectedActivity.WALKING, + DetectedActivity.ON_BICYCLE, + DetectedActivity.STILL, + ) + + // When + val actual = ACTIVITY_TRANSITION_TYPES + + // Then + assertArrayEquals(expected, actual) + } +} diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt similarity index 93% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt index 6e7424a..c80f6c3 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt @@ -1,9 +1,9 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -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.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt similarity index 90% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt index 224ae69..112eb03 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.DiagnosticEvent -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt similarity index 93% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt index b4a71d2..a9127d0 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import org.junit.Assert.assertEquals import org.junit.Test diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt similarity index 96% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt index dabd4b1..2255544 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.storage -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.boolean diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt similarity index 90% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt index 7b810b6..b13e024 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt @@ -1,8 +1,7 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.ContextWrapper -import android.content.Intent -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertSame @@ -37,7 +36,7 @@ class StorageHandlerTest { var createdMeasurement: String? = null var openedFile: String? = null - override fun persistRootAccess(intent: Intent): AppResult = AppResult.success(Unit) + override fun persistRootAccess(uri: String, grantFlags: Int): AppResult = AppResult.success(Unit) override fun hasConfiguredDirectory(): AppResult = AppResult.success(true) diff --git a/shared-resources/branding/drawable/ic_sensorbox_logo.xml b/shared-resources/branding/drawable/ic_sensorbox_logo.xml index 8d7fff9..9f2b212 100644 --- a/shared-resources/branding/drawable/ic_sensorbox_logo.xml +++ b/shared-resources/branding/drawable/ic_sensorbox_logo.xml @@ -1,4 +1,5 @@ diff --git a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml index 3e765ce..7248a34 100644 --- a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml +++ b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml index 3e765ce..7248a34 100644 --- a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + diff --git a/tools/emulator/run_phone_paired_recording_test.sh b/tools/emulator/run_phone_paired_recording_test.sh new file mode 100755 index 0000000..666bd34 --- /dev/null +++ b/tools/emulator/run_phone_paired_recording_test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +adb_bin="${ADB_BIN:-$(command -v adb)}" +phone_test="com.tomasrepcik.sensorbox.pairedrecording.PhonePairedRecordingEmulatorTest" +runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" +result_file="$(mktemp -t sensorbox-paired-recording.XXXXXX)" + +cleanup() { + rm -f "$result_file" +} +trap cleanup EXIT + +detect_emulator() { + local expected_role="$1" + local serial characteristics + local matches=() + + while read -r serial; do + [[ "$serial" == emulator-* ]] || continue + 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_emulator phone)}" +wear_serial="${WEAR_SERIAL:-$(detect_emulator wear)}" + +connect_emulators() { + "$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 +} + +require_paired_emulators() { + local pairing_status + pairing_status="$("$adb_bin" -s "$wear_serial" shell am broadcast \ + -a com.google.android.gms.wearable.EMULATOR \ + --es operation get-pairing-status)" + if [[ "$pairing_status" == *"Peer:[null"* ]]; then + echo "Wear emulator has no paired phone. Pair both devices with Android Studio's Pairing Assistant first." >&2 + exit 2 + fi +} + +cd "$repo_root" +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :wear:assembleDebug + +"$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 "$wear_serial" install -r wear/build/outputs/apk/debug/wear-debug.apk + +"$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 +connect_emulators +require_paired_emulators + +echo "Running phone-driven paired recording on $phone_serial with $wear_serial" +"$adb_bin" -s "$phone_serial" shell am instrument -w -r \ + -e class "$phone_test" "$runner" >"$result_file" +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 b2aaa5f..714581b 100755 --- a/tools/emulator/run_wear_sync_test.sh +++ b/tools/emulator/run_wear_sync_test.sh @@ -3,8 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" 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" +phone_test="com.tomasrepcik.sensorbox.measurements.sync.WearToPhoneSyncReceiverEmulatorTest" +wear_test="com.tomasrepcik.sensorbox.sync.WearToPhoneSyncSenderEmulatorTest" runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" result_directory="$(mktemp -d -t sensorbox-wear-sync.XXXXXX)" scenarios=( diff --git a/wear/lint.xml b/wear/lint.xml new file mode 100644 index 0000000..93f6fc5 --- /dev/null +++ b/wear/lint.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt similarity index 89% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt index 4524715..7e70354 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt similarity index 90% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt index 85216d1..d28e89c 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme class WearMenuRobot( private val rule: ComposeContentTestRule, diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt similarity index 95% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt index 7a5bc9a..e8f5282 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.compose.ui.test.junit4.v2.createComposeRule import org.junit.Assert.assertEquals diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt similarity index 98% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt index 0925a67..0b2d7d7 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.Manifest import android.content.Context @@ -9,8 +9,8 @@ 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.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recording.DefaultWatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt similarity index 98% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt index 27bce9c..f1dfb23 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.hardware.Sensor diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt similarity index 84% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt index 4de612d..2592702 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.sync import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -7,12 +7,13 @@ 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.SyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.GooglePlayWearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.files.GooglePlayWearFileTransferClient -import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncEmulatorFixture +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.sync.DefaultSyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.GooglePlayWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.sync.GooglePlayWearFileTransferClient +import com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncEmulatorFixture import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -44,7 +45,7 @@ class WearToPhoneSyncSenderEmulatorTest { @Test fun givenPairedEmulatorsWhenWearSyncsThenEverySupportedFileIsTransferred() = runBlocking { - val sync = SyncWatchMeasurementsUseCase( + val sync = DefaultSyncWatchMeasurementsUseCase( context = context, connectionRepository = GooglePlayWearConnectionRepository(context), transferClient = GooglePlayWearFileTransferClient(context), diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 7846938..2e4b27a 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -14,12 +14,14 @@ android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" tools:ignore="HighSamplingRate" /> - + @@ -44,11 +46,11 @@ diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt index 5eff9f5..8d5053e 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.activities +package com.tomasrepcik.sensorbox.bootstrap import android.content.Intent import android.os.Bundle @@ -16,15 +16,15 @@ import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.remote.interactions.RemoteActivityHelper import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardEffect -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardIntent -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardScreen -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardViewModel -import com.tomasrepcik.sensorbox.presentation.dashboard.WearRoute -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.home.WearDashboardEffect +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardScreen +import com.tomasrepcik.sensorbox.home.WearDashboardViewModel +import com.tomasrepcik.sensorbox.home.WearRoute +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt index 2f9b424..99f5967 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.activities +package com.tomasrepcik.sensorbox.bootstrap import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,9 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchScreen -import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchViewModel -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.phonelaunch.PhoneLaunchScreen +import com.tomasrepcik.sensorbox.phonelaunch.PhoneLaunchViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt similarity index 71% rename from wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt index a456bff..ac7e73a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.bootstrap import android.app.Application -import com.tomasrepcik.sensorbox.communication.WearRecordingSessionObserver -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingSessionObserver import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt deleted file mode 100644 index 152ffe8..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.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.WearRecordingOperation -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, operation: WearRecordingOperation) { - results.remove(Key(sessionId, operation)) - } - - suspend fun await(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult { - val key = Key(sessionId, operation) - results[key]?.let { return it } - return updates.first { result -> result.key() == key } - } - - private fun WearCommand.RecordingResult.key() = Key(sessionId, operation) - - private data class Key(val sessionId: String, val operation: WearRecordingOperation) - - private companion object { - const val BUFFER_SIZE = 16 - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt similarity index 97% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt index 5168f6e..8a07f73 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt similarity index 98% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt index ef18140..bbbf471 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt similarity index 98% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt index a755238..909cfe8 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.fillMaxWidth diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt similarity index 98% rename from wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt index fdc60c0..368f007 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.ui.theme +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt similarity index 76% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt index 517a3a1..c8f2483 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.diagnostics import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.CompositeDiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticMetadata -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics -import com.tomasrepcik.sensorbox.core.error.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.CompositeDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.DiagnosticMetadata +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.core.diagnostics.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt index 27472ce..0c5c8ba 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,7 +15,7 @@ import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode @Composable internal fun WearAppErrorScreen(errorCode: AppErrorCode, onDismiss: () -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt similarity index 91% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt index dd462c1..1f7ff7b 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource @@ -6,7 +6,7 @@ import androidx.compose.ui.res.stringResource import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearSecondaryButton +import com.tomasrepcik.sensorbox.design.WearSecondaryButton @Composable internal fun TransformingLazyColumnItemScope.WearBackButton( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt index a8f4ef9..ca69bab 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor enum class WearRoute { MENU, RECORD, LIVE, SETTINGS, ACTIVE } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt similarity index 59% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt index 54b4df0..a54966d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt @@ -1,8 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import androidx.compose.runtime.Composable -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuScreen -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuState +import com.tomasrepcik.sensorbox.diagnostics.WearAppErrorScreen +import com.tomasrepcik.sensorbox.menu.WearMenuScreen +import com.tomasrepcik.sensorbox.menu.WearMenuState +import com.tomasrepcik.sensorbox.recording.active.WearActiveScreen +import com.tomasrepcik.sensorbox.recording.live.WearLiveScreen +import com.tomasrepcik.sensorbox.recording.setup.WearRecordScreen +import com.tomasrepcik.sensorbox.settings.WearSettingsScreen @Composable fun WearDashboardScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt similarity index 91% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt index 9bc6605..962ca0d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt @@ -1,20 +1,20 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home 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.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingPermissionUseCase -import com.tomasrepcik.sensorbox.domain.sensors.GetWatchSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.ObserveSensorValuesUseCase -import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.recording.DefaultWatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.WatchRecordingPermissionUseCase +import com.tomasrepcik.sensorbox.recording.live.ObserveSensorValuesUseCase import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.sources.GetWatchSensorsUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt index a321a29..a523764 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu enum class WearMenuDestination { RECORD, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt similarity index 88% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt index 5b11d23..7b192bc 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt @@ -1,13 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearNavigationRow -import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearNavigationRow +import com.tomasrepcik.sensorbox.design.WearPageTitle @Composable fun WearMenuScreen(state: WearMenuState, onDestinationSelected: (WearMenuDestination) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt index a45bc11..294394a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt index 8703f6f..e47a15f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt similarity index 58% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt index 806bb43..85c4113 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandExchange -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt index 3bcc0ac..eb93766 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt @@ -1,15 +1,15 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingPermissionUseCase -import com.tomasrepcik.sensorbox.domain.sensors.GetWatchSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.toWatchSensorInfo -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.recording.WatchRecordingPermissionUseCase +import com.tomasrepcik.sensorbox.recording.sources.GetWatchSensorsUseCase +import com.tomasrepcik.sensorbox.recording.sources.toWatchSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo import kotlinx.coroutines.flow.first import javax.inject.Inject diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt similarity index 56% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt index 1b1bdb4..2146eaa 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt @@ -1,24 +1,30 @@ -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.domain.recording.WatchRecordingControlUseCase -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.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import kotlinx.coroutines.withTimeoutOrNull +package com.tomasrepcik.sensorbox.pairedrecording + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOperation +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import javax.inject.Inject +import javax.inject.Singleton +@Singleton class WearCommandHandler @Inject constructor( private val recording: WatchRecordingControlUseCase, private val environment: WearRecordingRequirementsUseCase, private val sendCommand: SendWearCommandUseCase, - private val phoneResults: PhoneRecordingResultInbox, + private val recordingCommands: RecordingCommandSender, + private val recordingResults: RecordingResultReceiver, + private val syncMeasurements: SyncWatchMeasurementsUseCase, ) { private val completedResults = mutableMapOf() private var activeSessionId: String? = null @@ -30,13 +36,14 @@ class WearCommandHandler @Inject constructor( WearCommand.RequestAvailableSensors -> sendAvailableSensors() + WearCommand.SyncMeasurements -> syncMeasurements().map { } + is WearCommand.RecordingResult -> { - phoneResults.publish(command) + recordingResults.receive(command) AppResult.success(Unit) } WearCommand.LaunchPhone, - WearCommand.SyncMeasurements, is WearCommand.AvailableSensors, -> AppResult.success(Unit) } @@ -44,36 +51,7 @@ class WearCommandHandler @Inject constructor( suspend fun onAutomaticStop(reason: WearStopReason): AppResult { val sessionId = activeSessionId ?: return AppResult.success(Unit) activeSessionId = null - val command = WearCommand.StopRecording(sessionId, reason) - phoneResults.clear(sessionId, WearRecordingOperation.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, WearRecordingOperation.STOP) - } - if (result != null) return result.toAppResult(retryCount) - } - } - } - - return lastSendError?.let { error -> AppResult.failure(error) } ?: AppResult.failure( - AppError( - code = AppErrorCode.TIMEOUT, - operation = "Propagate automatic Wear stop", - diagnosticMessage = "Phone stop result timed out", - context = mapOf( - "sessionId" to sessionId, - "retryCount" to RETRY_COUNT.toString(), - ), - isRetryable = true, - ), - ) + return recordingCommands.send(WearCommand.StopRecording(sessionId, reason)) } private suspend fun start(command: WearCommand.StartRecording): WearCommand.RecordingResult { @@ -154,30 +132,5 @@ class WearCommandHandler @Inject constructor( private fun cached(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult? = completedResults[ResultKey(sessionId, operation)] - 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 $operation result", - diagnosticMessage = errorMessage ?: "Phone $operation 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 operation: WearRecordingOperation) - - private companion object { - 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/communication/WearMessageDispatcher.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt similarity index 53% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt index e76018c..84746d4 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec import javax.inject.Inject class WearMessageDispatcher @Inject constructor( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt similarity index 78% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt index 16ace59..a9ee41e 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopReason -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopped -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopped +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt index 68c620d..2981c01 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode enum class PhoneLaunchStatus { IDLE, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt similarity index 71% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt index 38ece7c..dad3487 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt @@ -1,13 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle -import com.tomasrepcik.sensorbox.presentation.dashboard.WearAppErrorScreen +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearPrimaryButton +import com.tomasrepcik.sensorbox.design.WearSectionTitle +import com.tomasrepcik.sensorbox.diagnostics.WearAppErrorScreen @Composable fun PhoneLaunchScreen(state: PhoneLaunchState, onIntent: (PhoneLaunchIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt index a662034..11efc9f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import com.tomasrepcik.sensorbox.R diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt index b658f6f..1546c7f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch 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.AppFailureStore -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.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt similarity index 61% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt index 53cdc6b..aa6f61f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt @@ -1,9 +1,9 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.communication.WearRecordingEnvironment -import com.tomasrepcik.sensorbox.communication.WearRecordingRequirementsUseCase -import com.tomasrepcik.sensorbox.domain.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingEnvironment +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingRequirementsUseCase +import com.tomasrepcik.sensorbox.recording.DefaultWatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt similarity index 84% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt index 83ab4cf..7aede3a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt @@ -1,18 +1,18 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.content.Intent import android.hardware.SensorManager import androidx.core.content.ContextCompat -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.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt index fa12ec1..6df6dab 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest import android.content.Context diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt similarity index 96% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt index 48abaa2..59bdd2b 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt similarity index 89% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt index 942e1f3..3899fed 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.active import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,6 +12,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.ScreenScaffold +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState @Composable internal fun WearActiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt similarity index 97% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt index 636038b..498e7c0 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.live import android.content.Context import android.hardware.Sensor diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt similarity index 90% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt index 2a2f969..a0d8f80 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.live import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,10 +24,12 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearNavigationRow -import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearNavigationRow +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @Composable internal fun WearLiveSensor( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt similarity index 84% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt index 9fac904..c5ea0fa 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt @@ -1,10 +1,12 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.live import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.lineModel +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState @Composable internal fun WearLiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt similarity index 83% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt index 5bbe3f8..7a4f071 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource @@ -6,9 +6,10 @@ import androidx.compose.ui.res.stringResource import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.WearCheckboxRow -import com.tomasrepcik.sensorbox.presentation.WearPrimaryEdgeButton +import com.tomasrepcik.sensorbox.design.WearCheckboxRow +import com.tomasrepcik.sensorbox.design.WearPrimaryEdgeButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @Composable internal fun WearRecordingStartButton(selectedCount: Int, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt similarity index 73% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt index 100e207..75db86d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt @@ -1,11 +1,15 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearSectionTitle +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState +import com.tomasrepcik.sensorbox.home.wearMessageText @Composable internal fun WearRecordScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt similarity index 96% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt index fa0657b..e46e7ae 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.content.Context import android.hardware.Sensor diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt index 1055a61..fac5889 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo data class WatchSensorDescriptor( val type: Int, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt similarity index 95% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt index 1b3ee8b..36117b2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.settings import android.content.Context import androidx.datastore.core.DataStore diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt index c0f5b3f..762e68e 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.settings import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -10,12 +10,16 @@ import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope import androidx.wear.compose.material3.CircularProgressIndicator import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton -import com.tomasrepcik.sensorbox.presentation.WearRadioRow -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle -import com.tomasrepcik.sensorbox.presentation.WearSwitchRow +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearPrimaryButton +import com.tomasrepcik.sensorbox.design.WearRadioRow +import com.tomasrepcik.sensorbox.design.WearSectionTitle +import com.tomasrepcik.sensorbox.design.WearSwitchRow +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState +import com.tomasrepcik.sensorbox.home.wearMessageText @Composable internal fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt similarity index 66% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt index 9ece377..6022a97 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt @@ -1,7 +1,7 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.sync -import com.tomasrepcik.sensorbox.domain.sync.DefaultSyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.sync.DefaultSyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt index b53102a..91dd565 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt @@ -1,17 +1,17 @@ -package com.tomasrepcik.sensorbox.domain.sync +package com.tomasrepcik.sensorbox.sync 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 -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileMetadata -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileTransferClient +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import javax.inject.Inject diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 0a1884a..5e6d2c5 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -27,7 +27,6 @@ GPS Sources Live values - Start recording Back Waiting… Sync recordings @@ -46,8 +45,6 @@ Display stays on Display may sleep Stop & save - - Pick at least one sensor or GPS Permission required Sensor unavailable @@ -67,5 +64,4 @@ https://tomasrepcik.dev/sensorbox/terms-of-use https://tomasrepcik.dev/sensorbox/privacy-policy - Could not open the phone browser. The error was saved. diff --git a/wear/src/main/res/xml/backup_descriptor.xml b/wear/src/main/res/xml/backup_descriptor.xml new file mode 100644 index 0000000..ce0b324 --- /dev/null +++ b/wear/src/main/res/xml/backup_descriptor.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/wear/src/main/res/xml/data_extraction_rules.xml b/wear/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..b853a8d --- /dev/null +++ b/wear/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt similarity index 94% rename from wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt rename to wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt index c5a6d6a..642132a 100644 --- a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt +++ b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import android.content.res.Configuration import androidx.compose.foundation.background @@ -17,8 +17,8 @@ import com.android.tools.screenshot.PreviewTest 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.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @PreviewTest @ReadmeWearPreview diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt similarity index 94% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt index 89aef5c..f9e398e 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt @@ -1,7 +1,7 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import com.tomasrepcik.sensorbox.core.testing.AppPreferencesFixtures -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt similarity index 96% rename from wear/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt index ee8b5fa..b9f345c 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.menu import org.junit.Assert.assertEquals import org.junit.Test diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt similarity index 91% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt index 3cdf31b..5c66150 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import org.junit.Assert.assertEquals import org.junit.Test diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt similarity index 72% rename from wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt index 2a8565c..dc1ce39 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt @@ -1,23 +1,25 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -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.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -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.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 +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest @@ -102,6 +104,19 @@ class WearCommandHandlerTest { assertEquals(WearCommand.AvailableSensors(listOf(sensor())), fixture.repository.commands.single()) } + @Test + fun `Given a sync command When handled Then watch measurements are sent`() = runTest { + // Given + val fixture = Fixture() + + // When + val result = fixture.handler.handle(WearCommand.SyncMeasurements) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.syncCalls) + } + @Test fun `Given Wear timer expires while phone is unreachable When recording starts again Then Wear is available`() = runTest { @@ -130,11 +145,24 @@ class WearCommandHandlerTest { val repository = CapturingRepository() val recording = FakeWearRecordingControl() val requirements = FakeWearRecordingRequirements() + var syncCalls = 0 + private val sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)) + private val exchange = RecordingCommandExchange( + sendCommand = sendCommand, + peerCapability = "phone-capability", + peerPath = "/phone", + peerName = "phone", + ) val handler = WearCommandHandler( recording = recording, environment = requirements, - sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - phoneResults = PhoneRecordingResultInbox(), + sendCommand = sendCommand, + recordingCommands = exchange, + recordingResults = exchange, + syncMeasurements = SyncWatchMeasurementsUseCase { + syncCalls += 1 + AppResult.success(1) + }, ) } } diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt similarity index 94% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt index 0ee9060..1cc990c 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt similarity index 64% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt index 68cfa09..24bc0a2 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger 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.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 com.tomasrepcik.sensorbox.wearoslib.connection.FakeWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt similarity index 92% rename from wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt index 726b64a..e8754b1 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest import org.junit.Assert.assertEquals diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt similarity index 85% rename from wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt index 5d77e36..5296f42 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.hardware.Sensor import org.junit.Assert.assertFalse