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 |
-|:---:|:---:|
-|
|
|
-
-| Wear dashboard | Wear live-sensor picker |
-|:---:|:---:|
-|
|
|
-
-### First-run introduction
-
-| Welcome | Local data | Privacy and terms |
-|:---:|:---:|:---:|
-|
|
|
|
-
-| Android lifecycle | Battery optimization | Recording archive |
-|:---:|:---:|:---:|
-|
|
|
|
-
-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
+
+
+ Raw phone and watch sensors, recorded to files you own.
+
+
+
+
+
+
+
+
+## See it in use
+
+
+
+  |
+  |
+  |
+  |
+
+
+ | 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/AndroidManifest.xml b/WearOsLib/src/main/AndroidManifest.xml
index cc947c5..28d12d2 100644
--- a/WearOsLib/src/main/AndroidManifest.xml
+++ b/WearOsLib/src/main/AndroidManifest.xml
@@ -1 +1,10 @@
-
+
+
+
+
+
+
+
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 73%
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..1158842 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,26 +1,26 @@
-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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.isActive
+import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
@Singleton
class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext context: Context) :
@@ -35,19 +35,13 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
}
capabilityClient.addListener(capabilityListener, capability).await()
trySend(loadConnection(capability))
- val nodePolling = launch {
- while (isActive) {
- delay(NODE_POLL_INTERVAL_MILLIS)
- trySend(loadConnectedNodeConnection())
- }
- }
awaitClose {
- nodePolling.cancel()
capabilityClient.removeListener(capabilityListener)
}
- }.catch { error ->
- AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error)
+ }.retryWhen { _, _ ->
emit(WearConnection.Disconnected)
+ delay(CONNECTION_RETRY_DELAY_MILLIS.milliseconds)
+ true
}.distinctUntilChanged()
override suspend fun findNode(capability: String): WearNode? {
@@ -75,11 +69,6 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
?.let(WearConnection::Connected)
?: WearConnection.Disconnected
- private suspend fun loadConnectedNodeConnection(): WearConnection = WearNodeSelector
- .select(nodeClient.connectedNodes.await().map { it.toWearNode() })
- ?.let(WearConnection::Connected)
- ?: WearConnection.Disconnected
-
private fun Node.toWearNode() = WearNode(
id = id,
displayName = displayName,
@@ -87,6 +76,6 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext
)
private companion object {
- const val NODE_POLL_INTERVAL_MILLIS = 2_000L
+ const val CONNECTION_RETRY_DELAY_MILLIS = 2_000L
}
}
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/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt
deleted file mode 100644
index e4d1a14..0000000
--- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/GooglePlayWearFileTransferClient.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.tomasrepcik.sensorbox.wearoslib.files
-
-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 dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.tasks.await
-import kotlinx.coroutines.withContext
-import javax.inject.Inject
-
-class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext context: Context) :
- WearFileTransferClient {
- private val channelClient = Wearable.getChannelClient(context)
-
- override suspend fun send(
- nodeId: String,
- metadata: WearFileMetadata,
- input: () -> java.io.InputStream,
- ): AppResult = withContext(Dispatchers.IO) {
- val path = when (val encoded = WearFilePathCodec.encode(metadata)) {
- is AppResult.Failure -> return@withContext encoded
- is AppResult.Success -> encoded.value
- }
- val channel = when (val opened = openChannel(nodeId, path)) {
- is AppResult.Failure -> return@withContext opened
- is AppResult.Success -> opened.value
- }
- val transfer = writeChannel(channel, input)
- val close = closeChannel(channel)
- listOf(transfer, close)
- .combineAppResults(AppErrorCode.CONNECTIVITY, "Send Wear file")
- .withAppError(AppErrorCode.CONNECTIVITY, "Send Wear file")
- }
-
- override suspend fun receive(
- channel: ChannelClient.Channel,
- consume: (java.io.InputStream) -> AppResult,
- ): AppResult = withContext(Dispatchers.IO) {
- val opened = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") {
- channelClient.getInputStream(channel).await()
- }
- val transfer = when (opened) {
- is AppResult.Failure -> opened
- is AppResult.Success -> opened.value.use(consume)
- }
- val close = closeChannel(channel)
- listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive watch file")
- }
-
- private suspend fun openChannel(nodeId: String, path: String): AppResult =
- suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") {
- channelClient.openChannel(nodeId, path).await()
- }
-
- private suspend fun writeChannel(
- channel: ChannelClient.Channel,
- input: () -> java.io.InputStream,
- ): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") {
- input().use { source ->
- channelClient.getOutputStream(channel).await().use(source::copyTo)
- }
- }
-
- private suspend fun closeChannel(channel: ChannelClient.Channel): AppResult =
- suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") {
- channelClient.close(channel).await()
- }
-}
diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt
deleted file mode 100644
index f37880e..0000000
--- a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFileMetadata.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.tomasrepcik.sensorbox.wearoslib.files
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class WearFileMetadata(val measurementName: String, val fileName: String)
diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/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 67%
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..2bb2875 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,7 +1,6 @@
-package com.tomasrepcik.sensorbox.wearoslib.protocol
+package com.tomasrepcik.sensorbox.wearoslib.pairedrecording
-import com.tomasrepcik.sensorbox.core.error.AppErrorCode
-import kotlinx.serialization.SerialName
+import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
import kotlinx.serialization.Serializable
@Serializable
@@ -10,7 +9,22 @@ sealed interface WearCommand {
data object LaunchPhone : WearCommand
@Serializable
- data object SyncMeasurements : WearCommand
+ data class CheckWatchMeasurements(val requestId: String) : WearCommand
+
+ @Serializable
+ data class CopyWatchMeasurements(val requestId: String) : WearCommand
+
+ @Serializable
+ data class CancelWatchSync(val requestId: String) : WearCommand
+
+ @Serializable
+ data class WatchMeasurementsStatus(
+ val requestId: String,
+ val fileCount: Int,
+ val measurementCount: Int,
+ val finished: Boolean = false,
+ val failed: Boolean = false,
+ ) : WearCommand
@Serializable
data object RequestAvailableSensors : WearCommand
@@ -27,7 +41,6 @@ sealed interface WearCommand {
@Serializable
data class RecordingResult(
val sessionId: String,
- @SerialName("action")
val operation: WearRecordingOperation,
val outcome: WearRecordingOutcome,
@Serializable(with = AppErrorCodeNameSerializer::class)
@@ -45,6 +58,7 @@ data class WearRecordingRequest(
val sensorIds: List,
val includesGps: Boolean,
val durationMillis: Long = 0L,
+ val settings: WearRecordingSettings,
)
@Serializable
@@ -91,9 +105,21 @@ fun WearCommand.recordingSessionId(): String? = when (this) {
is WearCommand.RecordingResult -> sessionId
+ is WearCommand.CheckWatchMeasurements,
+ is WearCommand.CopyWatchMeasurements,
+ is WearCommand.CancelWatchSync,
+ is WearCommand.WatchMeasurementsStatus,
WearCommand.LaunchPhone,
WearCommand.RequestAvailableSensors,
is WearCommand.AvailableSensors,
- WearCommand.SyncMeasurements,
-> null
}
+
+@Serializable
+data class WearRecordingSettings(
+ val samplingPeriodIndex: Int,
+ val stopOnLowBattery: Boolean,
+ val useWakeLock: Boolean,
+ val gpsIntervalSeconds: Int,
+ val gpsMinDistanceMeters: Int,
+)
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 81%
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..04fe519 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
@@ -15,7 +15,7 @@ import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
object WearCommandCodec {
- const val PROTOCOL_VERSION = 5
+ const val PROTOCOL_VERSION = 7
private val json = Json {
classDiscriminator = "commandType"
@@ -57,7 +57,6 @@ internal object AppErrorCodeNameSerializer : KSerializer {
override fun deserialize(decoder: Decoder): AppErrorCode {
val name = decoder.decodeString()
- if (name == "MEASUREMENT") return AppErrorCode.RECORDING
return AppErrorCode.entries.firstOrNull { it.name == name }
?: error("Unknown application error code")
}
@@ -73,9 +72,16 @@ private const val MAX_ERROR_CONTEXT_ENTRIES = 16
private fun WearCommand.isValid(): Boolean = when (this) {
WearCommand.LaunchPhone,
WearCommand.RequestAvailableSensors,
- WearCommand.SyncMeasurements,
-> true
+ is WearCommand.CheckWatchMeasurements -> validSessionId(requestId)
+
+ is WearCommand.CopyWatchMeasurements -> validSessionId(requestId)
+
+ is WearCommand.CancelWatchSync -> validSessionId(requestId)
+
+ is WearCommand.WatchMeasurementsStatus -> isValid()
+
is WearCommand.AvailableSensors -> sensors.size <= MAX_SENSORS && sensors.all(WearSensorInfo::isValid)
is WearCommand.StartRecording -> isValid()
@@ -96,7 +102,8 @@ private fun WearCommand.StartRecording.isValid(): Boolean = validSessionId(sessi
request.folderName.isNotBlank() &&
request.folderName.length <= MAX_FOLDER_LENGTH &&
request.sensorIds.size <= MAX_SENSORS &&
- request.durationMillis >= 0L
+ request.durationMillis >= 0L &&
+ request.settings.isValid()
private fun WearCommand.RecordingResult.isValid(): Boolean = validSessionId(sessionId) &&
failureCount >= 0 &&
@@ -126,3 +133,9 @@ private fun WearCommand.RecordingResult.hasValidError(): Boolean = errorCode !=
private fun validSessionId(sessionId: String): Boolean = sessionId.isNotBlank() &&
sessionId.length <= MAX_SESSION_ID_LENGTH &&
sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }
+
+private fun WearCommand.WatchMeasurementsStatus.isValid(): Boolean = validSessionId(requestId) &&
+ fileCount >= 0 && measurementCount >= 0 && measurementCount <= fileCount
+
+private fun WearRecordingSettings.isValid(): Boolean =
+ samplingPeriodIndex in 0..3 && gpsIntervalSeconds > 0 && gpsMinDistanceMeters >= 0
diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt
new file mode 100644
index 0000000..edbadbe
--- /dev/null
+++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/GooglePlayWearFileTransferClient.kt
@@ -0,0 +1,76 @@
+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.failure.AppError
+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.tasks.await
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.io.InputStream
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext context: Context) :
+ WearFileTransferClient {
+ private val channelClient = Wearable.getChannelClient(context)
+ private val requests = TransferChannels { channelClient.close(it) }
+
+ override fun cancel(requestId: String) = requests.cancel(requestId)
+
+ override suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult =
+ withContext(Dispatchers.IO) {
+ suspendAppResult(AppErrorCode.CONNECTIVITY, "Send watch measurement") {
+ val path = WearFilePathCodec.encode(metadata).getOrThrow()
+ val opening = channelClient.openChannel(nodeId, path)
+ var opened: ChannelClient.Channel? = null
+ try {
+ opened = withTimeoutOrNull(15_000L.milliseconds) { opening.await() }
+ } finally {
+ if (opened == null) opening.addOnSuccessListener { channelClient.close(it) }
+ }
+ val channel = checkNotNull(opened) { "Opening watch transfer timed out" }
+ requests.use(metadata.requestId, channel, closeAfterSuccess = true) {
+ // Open the acknowledgement stream before the peer can finish saving.
+ channelClient.getInputStream(channel).await().use { acknowledgement ->
+ input().use { source ->
+ channelClient.getOutputStream(channel).await().use { output -> source.copyTo(output) }
+ }
+ check(acknowledgement.read() == FILE_SAVED) { "Phone did not confirm saving the measurement" }
+ }
+ }
+ }
+ }
+
+ override suspend fun reject(channel: ChannelClient.Channel): AppResult =
+ suspendAppResult(AppErrorCode.CONNECTIVITY, "Reject watch transfer") {
+ channelClient.close(channel)
+ }
+
+ override suspend fun receive(
+ channel: ChannelClient.Channel,
+ consume: (InputStream) -> AppResult,
+ ): AppResult = withContext(Dispatchers.IO) {
+ val metadata = WearFilePathCodec.decode(channel.path).getOrNull()
+ ?: return@withContext AppResult.failure(AppError(AppErrorCode.VALIDATION, "Decode watch transfer"))
+ suspendAppResult(AppErrorCode.CONNECTIVITY, "Receive watch measurement") {
+ requests.use(metadata.requestId, channel, closeAfterSuccess = false) {
+ channelClient.getInputStream(channel).await().use { consume(it).getOrThrow() }
+ channelClient.getOutputStream(channel).await().use { output ->
+ output.write(FILE_SAVED)
+ output.flush()
+ }
+ // The sender closes the channel after reading the acknowledgement.
+ }
+ }
+ }
+
+ private companion object {
+ const val FILE_SAVED = 1
+ }
+}
diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannels.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannels.kt
new file mode 100644
index 0000000..fcc555f
--- /dev/null
+++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannels.kt
@@ -0,0 +1,57 @@
+package com.tomasrepcik.sensorbox.wearoslib.sync
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.milliseconds
+
+/** Owns channel closure even when the transfer coroutine is blocked in stream I/O. */
+internal class TransferChannels(private val timeoutMillis: Long = 60_000L, private val close: (T) -> Unit) {
+ private val active = mutableMapOf()
+ private val cancelled = LinkedHashSet()
+
+ @Synchronized
+ fun cancel(requestId: String) {
+ cancelled.add(requestId)
+ if (cancelled.size > 128) cancelled.remove(cancelled.first())
+ active.filterValues { it == requestId }.keys.toList().forEach(close)
+ }
+
+ suspend fun use(requestId: String, channel: T, closeAfterSuccess: Boolean, work: suspend () -> R): R {
+ synchronized(this) {
+ if (requestId in cancelled) {
+ close(channel)
+ error("Watch transfer was cancelled")
+ }
+ active[channel] = requestId
+ }
+ return coroutineScope {
+ val finished = AtomicBoolean(false)
+ val expired = AtomicBoolean(false)
+ val deadline = launch(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) {
+ try {
+ delay(timeoutMillis.milliseconds)
+ expired.set(true)
+ } finally {
+ if (!finished.get()) close(channel)
+ }
+ }
+ try {
+ val result = work()
+ check(!expired.get()) { "Watch transfer timed out" }
+ synchronized(this@TransferChannels) {
+ check(requestId !in cancelled) { "Watch transfer was cancelled" }
+ }
+ finished.set(true)
+ result
+ } finally {
+ deadline.cancel()
+ if (!finished.get() || closeAfterSuccess) close(channel)
+ synchronized(this@TransferChannels) { active.remove(channel) }
+ }
+ }
+ }
+}
diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt
new file mode 100644
index 0000000..825bb1d
--- /dev/null
+++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFileMetadata.kt
@@ -0,0 +1,12 @@
+package com.tomasrepcik.sensorbox.wearoslib.sync
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WearFileMetadata(val measurementName: String, val fileName: String, val requestId: String) {
+ val isCommit: Boolean get() = fileName == COMMIT_FILE
+
+ companion object {
+ const val COMMIT_FILE = "__sensorbox_commit__"
+ }
+}
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 82%
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..5b5e355 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,17 +1,15 @@
-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 kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
+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.json.Json
object WearFilePathCodec {
fun encode(metadata: WearFileMetadata): AppResult = if (
- metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()
+ metadata.isValid()
) {
appResult(AppErrorCode.CONNECTIVITY, "Encode Wear file path") {
"$PREFIX/${JSON.encodeToString(metadata).encodePart()}"
@@ -29,7 +27,7 @@ object WearFilePathCodec {
require(encodedMetadata.isNotBlank() && '/' !in encodedMetadata)
JSON.decodeFromString(encodedMetadata.decodePart())
}.flatMap { metadata ->
- if (metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()) {
+ if (metadata.isValid()) {
AppResult.success(metadata)
} else {
AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path"))
@@ -37,6 +35,12 @@ object WearFilePathCodec {
}
}
+ private fun WearFileMetadata.isValid(): Boolean =
+ measurementName.isSafePathPart() && fileName.isSafePathPart() && requestId.isValidRequestId()
+
+ private fun String.isValidRequestId(): Boolean = isNotBlank() && length <= 128 &&
+ all { it.isLetterOrDigit() || it == '-' || it == '_' }
+
private fun String.encodePart(): String = encodeToByteArray().toBase64Url()
private fun String.decodePart(): String = decodeBase64Url().decodeToString()
@@ -86,7 +90,7 @@ object WearFilePathCodec {
private fun String.isSafePathPart(): Boolean =
isNotBlank() && length <= MAX_PART_LENGTH && '/' !in this && '\\' !in this && this != "." && this != ".."
- const val PREFIX = "/sensorbox/v2/file"
+ const val PREFIX = "/sensorbox/v3/file"
private const val MAX_PART_LENGTH = 120
private const val BYTE_MASK = 0xFF
private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
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 61%
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..70e4f67 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,11 +1,15 @@
-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 {
+ fun cancel(requestId: String)
+
suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult
+ suspend fun reject(channel: ChannelClient.Channel): AppResult
+
suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> AppResult): AppResult
}
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/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearTransferWorkService.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearTransferWorkService.kt
new file mode 100644
index 0000000..203aed0
--- /dev/null
+++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearTransferWorkService.kt
@@ -0,0 +1,97 @@
+package com.tomasrepcik.sensorbox.wearoslib.sync
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/** Keeps transfers alive without occupying the WearableListenerService callback looper. */
+class WearTransferWorkService : Service() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var running = 0
+ private var latestStartId = 0
+
+ override fun onCreate() {
+ super.onCreate()
+ val manager = getSystemService(NotificationManager::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL, "Watch sync", NotificationManager.IMPORTANCE_LOW),
+ )
+ }
+ val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, CHANNEL)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(this)
+ }
+ startForeground(
+ NOTIFICATION_ID,
+ notification.setContentTitle("Syncing watch measurements")
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setOngoing(true)
+ .build(),
+ )
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ latestStartId = startId
+ val work = intent?.getStringExtra(WORK_ID)?.let(pending::remove)
+ if (work != null) {
+ running += 1
+ scope.launch {
+ try {
+ work()
+ } finally {
+ running -= 1
+ if (running == 0) stopSelfResult(latestStartId)
+ }
+ }
+ } else if (running == 0) {
+ stopSelfResult(startId)
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val CHANNEL = "watch_measurement_sync"
+ private const val NOTIFICATION_ID = 2402
+ private const val WORK_ID = "workId"
+ private val pending = ConcurrentHashMap Unit>()
+
+ fun enqueue(context: Context, work: suspend () -> Unit): AppResult {
+ val id = UUID.randomUUID().toString()
+ pending[id] = work
+ return appResult(AppErrorCode.CONNECTIVITY, "Start watch sync") {
+ val intent = Intent(context, WearTransferWorkService::class.java).putExtra(WORK_ID, id)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ Unit
+ }.onFailure { pending.remove(id) }
+ }
+ }
+}
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/files/WearFilePathCodecTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodecTest.kt
deleted file mode 100644
index df5631c..0000000
--- a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/files/WearFilePathCodecTest.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.tomasrepcik.sensorbox.wearoslib.files
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class WearFilePathCodecTest {
- @Test
- fun `Given safe metadata When encoded and decoded Then names survive`() {
- val given = WearFileMetadata("recording_2026-08-13_12-30-00", "accelerometer.csv")
-
- val actual = WearFilePathCodec.decode(WearFilePathCodec.encode(given).getOrThrow())
-
- assertEquals(given, actual.getOrThrow())
- }
-
- @Test
- fun `Given a traversal path When decoded Then it is rejected`() {
- val unsafePath = "${WearFilePathCodec.PREFIX}/Li4/file"
-
- val actual = WearFilePathCodec.decode(unsafePath)
-
- assertTrue(actual.isFailure)
- }
-}
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..0620b36
--- /dev/null
+++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/RecordingCommandExchangeTest.kt
@@ -0,0 +1,144 @@
+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,
+ settings = WearRecordingSettings(0,
+ stopOnLowBattery = true,
+ useWakeLock = false,
+ gpsIntervalSeconds = 1,
+ gpsMinDistanceMeters = 0
+ ),
+ ),
+ )
+
+ 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/pairedrecording/UnsupportedWearCommandTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/UnsupportedWearCommandTest.kt
new file mode 100644
index 0000000..0576143
--- /dev/null
+++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/pairedrecording/UnsupportedWearCommandTest.kt
@@ -0,0 +1,93 @@
+package com.tomasrepcik.sensorbox.wearoslib.pairedrecording
+
+import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class UnsupportedWearCommandTest {
+ @Test
+ fun `Given an older protocol When decoded Then it is rejected`() {
+ // Given
+ val current = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow().decodeToString()
+ val older = current.replace("\"protocolVersion\":${WearCommandCodec.PROTOCOL_VERSION}", "\"protocolVersion\":6")
+
+ // When
+ val result = WearCommandCodec.decode(older.encodeToByteArray())
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given an unscoped sync command When decoded Then it is rejected`() {
+ // Given
+ val current = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow().decodeToString()
+ val obsolete = current.replace("LaunchPhone", "SyncMeasurements")
+
+ // When
+ val result = WearCommandCodec.decode(obsolete.encodeToByteArray())
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given missing recording settings When decoded Then the request is rejected`() {
+ // Given
+ val request = WearRecordingRequest(
+ "walk",
+ listOf(1),
+ false,
+ settings = WearRecordingSettings(0, true, false, 1, 0),
+ )
+ val payload = WearCommandCodec.encode(WearCommand.StartRecording("session", request)).getOrThrow()
+ val envelope = Json.parseToJsonElement(payload.decodeToString()).jsonObject
+ val command = envelope.getValue("command").jsonObject
+ val incomplete = JsonObject(command.getValue("request").jsonObject - "settings")
+ val changed = JsonObject(envelope + ("command" to JsonObject(command + ("request" to incomplete))))
+
+ // When
+ val result = WearCommandCodec.decode(changed.toString().encodeToByteArray())
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given an obsolete result field When decoded Then it is rejected`() {
+ // Given
+ val obsolete = failurePayload().replace("\"operation\":", "\"action\":")
+
+ // When
+ val result = WearCommandCodec.decode(obsolete.encodeToByteArray())
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given an obsolete error name When decoded Then it is rejected`() {
+ // Given
+ val obsolete = failurePayload().replace("\"RECORDING\"", "\"MEASUREMENT\"")
+
+ // When
+ val result = WearCommandCodec.decode(obsolete.encodeToByteArray())
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ private fun failurePayload(): String = WearCommandCodec.encode(
+ WearCommand.RecordingResult(
+ sessionId = "session",
+ operation = WearRecordingOperation.START,
+ outcome = WearRecordingOutcome.FAILED,
+ errorCode = AppErrorCode.RECORDING,
+ errorOperation = "Start recording",
+ errorMessage = "Fixture failure",
+ ),
+ ).getOrThrow().decodeToString()
+}
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 84%
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..6a80579 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,22 +1,31 @@
-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
class WearCommandCodecTest {
@Test
- fun `Given protocol v5 commands When round tripped Then every field survives`() {
+ fun `Given current commands When round tripped Then every field survives`() {
val request = WearRecordingRequest(
folderName = "shared_session",
sensorIds = listOf(1, 4, 21),
includesGps = true,
durationMillis = 45_000L,
+ settings = WearRecordingSettings(0,
+ stopOnLowBattery = true,
+ useWakeLock = false,
+ gpsIntervalSeconds = 1,
+ gpsMinDistanceMeters = 0
+ ),
)
val commands = listOf(
WearCommand.LaunchPhone,
- WearCommand.SyncMeasurements,
+ WearCommand.CheckWatchMeasurements("check"),
+ WearCommand.CopyWatchMeasurements("copy"),
+ WearCommand.CancelWatchSync("copy"),
+ WearCommand.WatchMeasurementsStatus("copy", 3, 1, finished = true),
WearCommand.RequestAvailableSensors,
WearCommand.AvailableSensors(
listOf(
diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannelsTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannelsTest.kt
new file mode 100644
index 0000000..4ecb942
--- /dev/null
+++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/TransferChannelsTest.kt
@@ -0,0 +1,87 @@
+package com.tomasrepcik.sensorbox.wearoslib.sync
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class TransferChannelsTest {
+ @Test
+ fun `Given blocked channel input When the request is cancelled Then the stream closes and work exits`() =
+ runBlocking {
+ // Given
+ val fixture = BlockingChannelFixture()
+ val job = async(Dispatchers.IO) { runCatching { fixture.transfer() } }
+ assertTrue(fixture.started.await(5, TimeUnit.SECONDS))
+
+ // When
+ fixture.channels.cancel("request")
+
+ // Then
+ assertTrue(job.await().isFailure)
+ assertTrue(fixture.closed.await(5, TimeUnit.SECONDS))
+ }
+
+ @Test
+ fun `Given blocked channel input When the owner is cancelled Then channel closure unblocks the child`() =
+ runBlocking {
+ // Given
+ val fixture = BlockingChannelFixture()
+ val job = launch(Dispatchers.IO) { fixture.transfer() }
+ assertTrue(fixture.started.await(5, TimeUnit.SECONDS))
+
+ // When
+ job.cancelAndJoin()
+
+ // Then
+ assertTrue(fixture.closed.await(5, TimeUnit.SECONDS))
+ assertTrue(job.isCompleted)
+ }
+
+ @Test
+ fun `Given a stalled channel When its deadline expires Then no success is returned`() = runBlocking {
+ // Given
+ val fixture = BlockingChannelFixture(timeoutMillis = 30)
+
+ // When
+ val result = runCatching { fixture.transfer() }
+
+ // Then
+ assertTrue(result.isFailure)
+ assertTrue(fixture.closed.await(5, TimeUnit.SECONDS))
+ }
+
+ @Test
+ fun `Given cancellation before channel open When the channel arrives Then its contents are never consumed`() =
+ runBlocking {
+ // Given
+ val fixture = BlockingChannelFixture()
+ fixture.channels.cancel("request")
+ var consumed = false
+
+ // When
+ val result = runCatching { fixture.channels.use("request", "channel", false) { consumed = true } }
+
+ // Then
+ assertTrue(result.isFailure)
+ assertFalse(consumed)
+ assertTrue(fixture.closed.await(5, TimeUnit.SECONDS))
+ }
+
+ private class BlockingChannelFixture(timeoutMillis: Long = 5_000) {
+ val started = CountDownLatch(1)
+ val closed = CountDownLatch(1)
+ val channels = TransferChannels(timeoutMillis) { closed.countDown() }
+
+ suspend fun transfer() = channels.use("request", "channel", closeAfterSuccess = true) {
+ started.countDown()
+ check(closed.await(5, TimeUnit.SECONDS)) { "Channel was never closed" }
+ }
+ }
+}
diff --git a/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt
new file mode 100644
index 0000000..53616a1
--- /dev/null
+++ b/WearOsLib/src/test/java/com/tomasrepcik/sensorbox/wearoslib/sync/WearFilePathCodecTest.kt
@@ -0,0 +1,50 @@
+package com.tomasrepcik.sensorbox.wearoslib.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WearFilePathCodecTest {
+ @Test
+ fun `Given an empty request identifier When encoded Then the file is rejected`() {
+ // Given
+ val metadata = WearFileMetadata("walk", "sensor.csv", "")
+
+ // When
+ val result = WearFilePathCodec.encode(metadata)
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given missing request metadata When decoded Then the file is rejected`() {
+ // Given
+ val json = """{"measurementName":"walk","fileName":"sensor.csv"}"""
+ val encoded = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(json.toByteArray())
+
+ // When
+ val result = WearFilePathCodec.decode("${WearFilePathCodec.PREFIX}/$encoded")
+
+ // Then
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `Given safe metadata When encoded and decoded Then names survive`() {
+ val given = WearFileMetadata("recording_2026-08-13_12-30-00", "accelerometer.csv", "request")
+
+ val actual = WearFilePathCodec.decode(WearFilePathCodec.encode(given).getOrThrow())
+
+ assertEquals(given, actual.getOrThrow())
+ }
+
+ @Test
+ fun `Given a traversal path When decoded Then it is rejected`() {
+ val unsafePath = "${WearFilePathCodec.PREFIX}/Li4/file"
+
+ val actual = WearFilePathCodec.decode(unsafePath)
+
+ assertTrue(actual.isFailure)
+ }
+}
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/emulator/WearToPhoneSyncReceiverEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt
deleted file mode 100644
index 334d158..0000000
--- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncReceiverEmulatorTest.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.tomasrepcik.sensorbox.emulator
-
-import android.content.Context
-import android.util.Log
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncEmulatorFixture
-import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncFileFixture
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import java.io.File
-
-@RunWith(AndroidJUnit4::class)
-class WearToPhoneSyncReceiverEmulatorTest {
- private val context: Context = ApplicationProvider.getApplicationContext()
- private val scenario = WearSyncEmulatorFixture.scenario(
- requireNotNull(
- InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT),
- ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" },
- )
- private val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY)
-
- @Before
- fun preparePhoneDestination() {
- root.deleteRecursively()
- scenario.transferredFiles.forEach { fixture ->
- fixture.existingPhoneContent?.let { existingContent ->
- receivedFile(fixture).apply {
- parentFile?.mkdirs()
- writeBytes(existingContent)
- }
- }
- }
- }
-
- @Test
- fun givenPairedEmulatorsWhenWearSendsFilesThenPhoneReceivesExactBytes() {
- Log.i(LOG_TAG, "READY_FOR_WEAR_TRANSFER")
-
- scenario.transferredFiles.forEach { fixture ->
- val received = receivedFile(fixture)
- assertTrue("Timed out waiting for $received", waitFor(received, fixture.content))
- assertArrayEquals("Wrong bytes in $received", fixture.content, received.readBytes())
- }
- scenario.files.filterNot(WearSyncFileFixture::shouldTransfer).forEach { fixture ->
- assertTrue("Unsupported file was transferred: ${receivedFile(fixture)}", !receivedFile(fixture).exists())
- }
- }
-
- private fun receivedFile(fixture: WearSyncFileFixture) =
- File(root, "${fixture.receivedMeasurementName}/${fixture.fileName}")
-
- private fun waitFor(receivedFile: File, expectedContent: ByteArray): Boolean {
- repeat(MAX_ATTEMPTS) {
- if (receivedFile.isFile && receivedFile.contentEquals(expectedContent)) return true
- Thread.sleep(POLL_INTERVAL_MILLIS)
- }
- return false
- }
-
- private fun File.contentEquals(expectedContent: ByteArray): Boolean =
- length() == expectedContent.size.toLong() && readBytes().contentEquals(expectedContent)
-
- private companion object {
- const val LOG_TAG = "SensorBoxEmulatorTest"
- const val POLL_INTERVAL_MILLIS = 500L
- const val MAX_ATTEMPTS = 120
- }
-}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt
new file mode 100644
index 0000000..86fb1cd
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/MeasurementScreensTest.kt
@@ -0,0 +1,236 @@
+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.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+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 MeasurementScreensTest {
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun givenMeasurementListWhenMeasurementIsTappedThenItsIdentifierIsSelected() {
+ var selectedId: String? = null
+ composeRule.setContent {
+ SensorBoxTheme {
+ MeasurementsScreen(
+ state = MeasurementsState(measurements = listOf(summary)),
+ onIntent = { intent ->
+ if (intent is MeasurementsIntent.OpenDetails) selectedId = intent.measurementId
+ },
+ onBack = {},
+ )
+ }
+ }
+
+ composeRule.onNodeWithText("Morning walk").assertIsDisplayed().performClick()
+ composeRule.runOnIdle { assertEquals(summary.id, selectedId) }
+ }
+
+ @Test
+ fun givenMeasurementDetailsThenMetadataAndFilesCanBeInspected() {
+ var selectedFile: String? = null
+ composeRule.setContent {
+ SensorBoxTheme {
+ MeasurementDetailsScreen(
+ state = MeasurementDetailsState(details = details),
+ onIntent = { intent ->
+ if (intent is MeasurementDetailsIntent.OpenFile) selectedFile = intent.fileId
+ },
+ onBack = {},
+ )
+ }
+ }
+
+ composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Pixel fixture"))
+ composeRule.onNodeWithText("Pixel fixture").assertIsDisplayed()
+ composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Accelerometer"))
+ composeRule.onNodeWithText("Accelerometer").assertIsDisplayed().performClick()
+ composeRule.runOnIdle { assertEquals(sensorFile.id, selectedFile) }
+ }
+
+ @Test
+ fun 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"),
+ samples = listOf(
+ SensorSeriesSample(1, listOf(0.1, 0.2, 0.3)),
+ SensorSeriesSample(2, listOf(0.4, 0.5, 0.6)),
+ ),
+ truncated = false,
+ ),
+ sensorMetadata,
+ onIntent = { chartIntent = it },
+ )
+
+ composeRule.onNodeWithText("2 chart samples").assertIsDisplayed()
+ composeRule.onNodeWithText("Sensor details").assertIsDisplayed()
+ composeRule.onNodeWithText("Bosch accelerometer").assertIsDisplayed()
+ composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Zoom in"))
+ composeRule.onNodeWithText("x, y, z").assertIsDisplayed()
+ composeRule.onNodeWithText("Zoom in").assertIsDisplayed().performClick()
+ composeRule.runOnIdle {
+ assertEquals(MeasurementPreviewIntent.ZoomSensorChartTimeWindow(2f, 0.5f), chartIntent)
+ }
+ }
+
+ @Test
+ fun givenGpsSamplesThenPreviewKeepsCoordinatePrecision() {
+ showPreview(
+ gpsFile,
+ MeasurementFileContent.GpsCoordinates(
+ listOf(GpsCoordinate(1_725_000_000_000, 48.148596, 17.107748, 140.0, 3.0, null, null, "gps")),
+ truncated = false,
+ ),
+ )
+
+ composeRule.onNodeWithText("Coordinate 1").assertIsDisplayed()
+ composeRule.onNodeWithText("48.148596 , 17.107748").assertIsDisplayed()
+ }
+
+ @Test
+ fun givenActivityTransitionsWhenOpenedThenNamedEventsReplaceTheChart() {
+ // Given
+ val content = MeasurementFileContent.SensorSeries(
+ columns = listOf("activity", "enter_exit"),
+ samples = listOf(
+ SensorSeriesSample(1_725_000_000_000, listOf(7.0, 0.0)),
+ SensorSeriesSample(1_725_000_001_000, listOf(7.0, 1.0)),
+ ),
+ truncated = false,
+ )
+
+ // When
+ showPreview(sensorFile.copy(id = "activity_transitions.csv", name = "Activity transitions"), content)
+
+ // Then
+ composeRule.onNodeWithText("Walking started").assertIsDisplayed()
+ composeRule.onNodeWithText("Walking ended").assertIsDisplayed()
+ composeRule.onNodeWithText("2 recorded events").assertIsDisplayed()
+ composeRule.onNodeWithText("2 chart samples").assertDoesNotExist()
+ composeRule.onNodeWithText("Zoom in").assertDoesNotExist()
+ }
+
+ @Test
+ fun givenDetectedStepsWhenOpenedThenNumberedEventsReplaceTheChart() {
+ // Given
+ val content = MeasurementFileContent.SensorSeries(
+ columns = listOf("step"),
+ samples = listOf(SensorSeriesSample(1_725_000_000_000, listOf(1.0))),
+ truncated = false,
+ )
+
+ // When
+ showPreview(sensorFile.copy(id = "step_detector.csv", name = "Step detector"), content)
+
+ // Then
+ composeRule.onNodeWithText("Step detected · 1").assertIsDisplayed()
+ composeRule.onNodeWithText("Zoom in").assertDoesNotExist()
+ }
+
+ @Test
+ fun givenActivityUpdatesWhenOpenedThenTheChartHasReadableLegendLabels() {
+ // Given
+ val content = MeasurementFileContent.SensorSeries(
+ columns = listOf("still", "on_foot", "walking", "running", "vehicle", "bike", "unknown", "tilting"),
+ samples = listOf(
+ SensorSeriesSample(1_725_000_000_000, listOf(20.0, 80.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0)),
+ ),
+ truncated = false,
+ )
+
+ // When
+ showPreview(sensorFile.copy(id = "activity_updates.csv", name = "Activity updates"), content)
+
+ // Then
+ composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Walking"))
+ composeRule.onNodeWithText("Walking").assertIsDisplayed()
+ composeRule.onNodeWithText("Still").assertIsDisplayed()
+ composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Tilting"))
+ composeRule.onNodeWithText("Tilting").assertIsDisplayed()
+ composeRule.onNodeWithText("Cycling").assertIsDisplayed()
+ }
+
+ private fun showPreview(
+ file: MeasurementFileSummary,
+ content: MeasurementFileContent,
+ metadata: List = emptyList(),
+ onIntent: (MeasurementPreviewIntent) -> Unit = {},
+ ) {
+ composeRule.setContent {
+ SensorBoxTheme {
+ MeasurementPreviewScreen(
+ state = MeasurementPreviewState(
+ file = file,
+ content = content,
+ sensorMetadata = metadata,
+ ),
+ onIntent = onIntent,
+ onBack = {},
+ )
+ }
+ }
+ }
+
+ private companion object {
+ val summary = MeasurementSummary("session-1", "Morning walk", 1_725_000_000_000, "2026-08-25", 2)
+ val sensorFile = MeasurementFileSummary("accelerometer.csv", "Accelerometer", MeasurementFileKind.SENSOR, 256)
+ val gpsFile = MeasurementFileSummary("GPS.csv", "GPS", MeasurementFileKind.GPS, 128)
+ val sensorMetadata = listOf(MeasurementMetadataEntry("sensor", "Bosch accelerometer"))
+ val details = MeasurementDetails(
+ summary,
+ listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")),
+ listOf(sensorFile, gpsFile),
+ sensorMetadataByFile = mapOf(sensorFile.id to sensorMetadata),
+ )
+ }
+}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/list/RecordingGroupsScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/list/RecordingGroupsScreenTest.kt
new file mode 100644
index 0000000..831d310
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/list/RecordingGroupsScreenTest.kt
@@ -0,0 +1,39 @@
+package com.tomasrepcik.sensorbox.measurements.list
+
+import androidx.compose.foundation.lazy.LazyColumn
+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.design.SensorBoxTheme
+import com.tomasrepcik.sensorbox.measurements.components.measurementArchiveItems
+import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+
+class RecordingGroupsScreenTest {
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun givenLinkedMeasurementsWhenWatchIsTappedThenWatchDetailsOpenWithinTheSharedRecording() {
+ // Given
+ val phone = MeasurementSummary("walk", "walk", 100L, "Today", 1, "session", "phone", "Morning walk")
+ val watch = phone.copy(id = "WEAR_walk", device = "watch")
+ val state = MeasurementsState(listOf(phone, watch))
+ val intents = mutableListOf()
+ composeRule.setContent {
+ SensorBoxTheme { LazyColumn { measurementArchiveItems(state, intents::add) } }
+ }
+
+ // When
+ composeRule.onNodeWithText("Morning walk").assertIsDisplayed()
+ composeRule.onNodeWithText("Phone + Watch · Paired recording").assertIsDisplayed()
+ composeRule.onNodeWithText("Phone").assertIsDisplayed()
+ composeRule.onNodeWithText("Watch").performClick()
+
+ // Then
+ assertEquals(listOf(MeasurementsIntent.OpenDetails("WEAR_walk")), intents)
+ }
+}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/SelectedArchiveFixture.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/SelectedArchiveFixture.kt
new file mode 100644
index 0000000..39da437
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/SelectedArchiveFixture.kt
@@ -0,0 +1,15 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import android.content.Context
+import androidx.documentfile.provider.DocumentFile
+
+internal fun selectedArchive(context: Context): DocumentFile {
+ val permission = context.contentResolver.persistedUriPermissions
+ .filter { it.isReadPermission && it.isWritePermission }
+ .maxByOrNull { it.persistedTime }
+ ?: error("Choose a recording folder in the phone app before running sync emulator tests")
+ return checkNotNull(DocumentFile.fromTreeUri(context, permission.uri))
+}
+
+internal fun DocumentFile.readBytes(context: Context): ByteArray =
+ checkNotNull(context.contentResolver.openInputStream(uri)).use { it.readBytes() }
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchArchiveVisibilityTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchArchiveVisibilityTest.kt
new file mode 100644
index 0000000..aa154a1
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchArchiveVisibilityTest.kt
@@ -0,0 +1,25 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.tomasrepcik.sensorbox.measurements.storage.AndroidMeasurementRepository
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WatchArchiveVisibilityTest {
+ @Test
+ fun givenWatchFoldersInArchiveWhenMeasurementsLoadThenEveryWatchFolderIsListed() = runBlocking {
+ // Given
+ val context = ApplicationProvider.getApplicationContext()
+ val folders = selectedArchive(context).listFiles()
+ .filter { it.isDirectory && it.name.orEmpty().startsWith("WEAR_") }
+ .mapNotNull { it.name }
+
+ // When
+ val measurements = AndroidMeasurementRepository(context).loadMeasurements().getOrThrow()
+
+ // Then
+ assertTrue(measurements.map { it.id }.containsAll(folders))
+ }
+}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchMeasurementCommitTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchMeasurementCommitTest.kt
new file mode 100644
index 0000000..7089c67
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchMeasurementCommitTest.kt
@@ -0,0 +1,122 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.tomasrepcik.sensorbox.core.storage.NativeDocumentStorage
+import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock
+import com.tomasrepcik.sensorbox.core.storage.measurementFingerprint
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.IOException
+import java.io.InputStream
+import java.util.UUID
+
+@RunWith(AndroidJUnit4::class)
+class WatchMeasurementCommitTest {
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val root by lazy { selectedArchive(context) }
+ private val name = "sync_test_${UUID.randomUUID()}"
+ private val storage = NativeDocumentStorage(context)
+ private val bytes = "complete samples".toByteArray()
+ private val fingerprint = measurementFingerprint(listOf("sensor.csv" to bytes::inputStream))
+ private val imports = mutableListOf()
+
+ @After
+ fun cleanFixture() {
+ imports.forEach { it.discard() }
+ root.findFile(name)?.delete()
+ }
+
+ @Test
+ fun givenInterruptedFileWhenCopyFailsThenExistingMeasurementRemainsIntact() {
+ // Given
+ writeFixture(name, "sensor.csv", bytes)
+ val pending = stage()
+ val interrupted = object : InputStream() {
+ override fun read(): Int = throw IOException("Disconnected fixture")
+ }
+
+ // When
+ val result = pending.copy("sensor.csv", "text/csv", interrupted)
+
+ // Then
+ assertTrue(result.isFailure)
+ assertArrayEquals(bytes, checkNotNull(root.findFile(name)?.findFile("sensor.csv")).readBytes(context))
+ }
+
+ @Test
+ fun givenStagedFolderWhenCommittedThenOnlyCompleteReplacementIsPublished() {
+ // Given
+ writeFixture(name, "old.csv", "old".toByteArray())
+ val pending = stage()
+ pending.copy("sensor.csv", "text/csv", bytes.inputStream()).getOrThrow()
+ assertTrue(root.findFile(name)?.findFile("sensor.csv") == null)
+
+ // When
+ pending.commit(fingerprint).getOrThrow()
+
+ // Then
+ val published = checkNotNull(root.findFile(name))
+ assertEquals(listOf("sensor.csv"), published.listFiles().map { it.name })
+ assertArrayEquals(bytes, checkNotNull(published.findFile("sensor.csv")).readBytes(context))
+ }
+
+ @Test
+ fun givenIncompleteFolderWhenCommitFailsThenNoMeasurementIsPublished() {
+ // Given
+ val pending = stage()
+ pending.copy("sensor.csv", "text/csv", "partial".byteInputStream()).getOrThrow()
+
+ // When
+ val result = pending.commit(fingerprint)
+
+ // Then
+ assertTrue(result.isFailure)
+ assertTrue(root.findFile(name) == null)
+ }
+
+ @Test
+ fun givenSyncInProgressWhenArchiveSelectionReturnsThenPermissionIsNotChanged() {
+ // Given
+ val lock = MeasurementSyncLock()
+ val guarded = NativeDocumentStorage(context, lock)
+ lock.begin("request")
+
+ // When
+ val result = guarded.persistRootAccess("invalid-but-never-used", 3)
+
+ // Then
+ assertTrue(result.isFailure)
+ assertTrue(guarded.hasConfiguredDirectory().getOrThrow())
+ lock.finish("request")
+ assertFalse(lock.busy.value)
+ }
+
+ @Test
+ fun givenInterruptedReplacementWhenSyncResumesThenThePreviousFolderIsRecovered() {
+ // Given
+ val backup = com.tomasrepcik.sensorbox.core.storage.BACKUP_MEASUREMENT_PREFIX + name
+ writeFixture(backup, "sensor.csv", bytes)
+ val restarted = NativeDocumentStorage(context)
+
+ // When
+ val pending = restarted.beginMeasurementImport(name).getOrThrow().also(imports::add)
+
+ // Then
+ assertArrayEquals(bytes, checkNotNull(root.findFile(name)?.findFile("sensor.csv")).readBytes(context))
+ assertTrue(root.findFile(backup) == null)
+ pending.discard().getOrThrow()
+ }
+
+ private fun writeFixture(folder: String, file: String, content: ByteArray) {
+ storage.openMeasurementFile(folder, "text/csv", file).getOrThrow().use { it.write(content) }
+ }
+
+ private fun stage() = storage.beginMeasurementImport(name).getOrThrow().also(imports::add)
+}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncBadgeTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncBadgeTest.kt
new file mode 100644
index 0000000..7f2521e
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncBadgeTest.kt
@@ -0,0 +1,132 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import com.tomasrepcik.sensorbox.design.SensorBoxTheme
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class WatchSyncBadgeTest {
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun givenOpenDialogWhenRefreshIsTappedThenOnlyMeasurementCountIsRequested() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.AVAILABLE)
+ val intents = mutableListOf()
+ composeRule.setContent { SensorBoxTheme { WatchSyncContent(state, true, intents::add) } }
+ composeRule.onNodeWithContentDescription("Watch up to date. Nothing to sync").performClick()
+
+ // When
+ composeRule.onNodeWithContentDescription("Check watch again").assertIsDisplayed().performClick()
+
+ // Then
+ assertEquals(listOf(WatchSyncIntent.CHECK), intents)
+ }
+
+ @Test
+ fun givenFailureWhenIconIsTappedThenIssueAndRetryAreShown() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.FAILED, message = "Watch connection timed out")
+ val intents = mutableListOf()
+ composeRule.setContent { SensorBoxTheme { WatchSyncContent(state, false, intents::add) } }
+
+ // When
+ composeRule.onNodeWithContentDescription("Sync failed · tap to retry").performClick()
+
+ // Then
+ composeRule.onNodeWithText("Watch connection timed out").assertIsDisplayed()
+ assertTrue(intents.isEmpty())
+ composeRule.onNodeWithText("Retry").performClick()
+ assertEquals(listOf(WatchSyncIntent.RETRY), intents)
+ }
+
+ @Test
+ fun givenAvailableMeasurementsWhenOpeningDialogThenOnlySyncButtonRequestsTransfer() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.AVAILABLE, measurementCount = 2, fileCount = 15)
+ val intents = mutableListOf()
+ composeRule.setContent { SensorBoxTheme { WatchSyncContent(state, true, intents::add) } }
+
+ // When
+ composeRule.onNodeWithContentDescription("Fetch 2 watch measurements").performClick()
+
+ // Then
+ composeRule.onNodeWithText("Sync from watch").assertIsDisplayed()
+ assertTrue(intents.isEmpty())
+ composeRule.onNodeWithText("Sync from watch").performClick()
+ assertEquals(listOf(WatchSyncIntent.COPY), intents)
+ }
+
+ @Test
+ fun givenActiveTransferWhenProgressIsClosedAndReopenedThenProgressRemainsVisibleWithoutAnotherRequest() {
+ // Given
+ val state = WatchSyncState(
+ status = WatchSyncStatus.COPYING,
+ measurementCount = 2,
+ fileCount = 15,
+ receivedFiles = listOf("WEAR_walk/accelerometer.csv"),
+ )
+ val intents = mutableListOf()
+ composeRule.setContent { SensorBoxTheme { WatchSyncContent(state, false, intents::add) } }
+
+ // When
+ composeRule.onNodeWithContentDescription("Watch syncing. Show progress").performClick()
+ composeRule.onNodeWithText("1 of 15 files saved").assertIsDisplayed()
+ composeRule.onNodeWithText("Close").performClick()
+ composeRule.onNodeWithContentDescription("Watch syncing. Show progress").performClick()
+
+ // Then
+ composeRule.onNodeWithText("1 of 15 files saved").assertIsDisplayed()
+ composeRule.onNodeWithText("WEAR_walk/accelerometer.csv").assertDoesNotExist()
+ composeRule.onNodeWithText("Recording folder settings").assertDoesNotExist()
+ assertTrue(intents.isEmpty())
+ }
+
+ @Test
+ fun givenNoPendingMeasurementsWhenDisplayedThenWatchIsUpToDate() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.AVAILABLE)
+
+ // When
+ composeRule.setContent { SensorBoxTheme { WatchSyncButton(state, true) {} } }
+
+ // Then
+ composeRule.onNodeWithContentDescription("Watch up to date. Nothing to sync").assertIsDisplayed()
+ composeRule.onNodeWithText("0").assertDoesNotExist()
+ }
+
+ @Test
+ fun givenTwoMeasurementsWithManyFilesWhenDisplayedThenBadgeShowsTwoAndOpensCopy() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.AVAILABLE, measurementCount = 2, fileCount = 15)
+ var opened = false
+ composeRule.setContent { SensorBoxTheme { WatchSyncButton(state, true) { opened = true } } }
+
+ // When
+ composeRule.onNodeWithContentDescription("Fetch 2 watch measurements").assertIsDisplayed().performClick()
+
+ // Then
+ composeRule.onNodeWithText("2").assertIsDisplayed()
+ composeRule.onNodeWithText("15").assertDoesNotExist()
+ assertTrue(opened)
+ }
+
+ @Test
+ fun givenCompletedCopyWhenDisplayedThenWatchIsUpToDate() {
+ // Given
+ val state = WatchSyncState(status = WatchSyncStatus.COMPLETE, measurementCount = 2, fileCount = 15)
+
+ // When
+ composeRule.setContent { SensorBoxTheme { WatchSyncButton(state, true) {} } }
+
+ // Then
+ composeRule.onNodeWithContentDescription("Watch up to date. Nothing to sync").assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt
new file mode 100644
index 0000000..692c90d
--- /dev/null
+++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/measurements/sync/WearToPhoneSyncReceiverEmulatorTest.kt
@@ -0,0 +1,80 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import android.content.Context
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncEmulatorFixture
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncFileFixture
+import dagger.hilt.android.EntryPointAccessors
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WearToPhoneSyncReceiverEmulatorTest {
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val scenario = WearSyncEmulatorFixture.scenario(
+ requireNotNull(
+ InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT),
+ ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" },
+ )
+ private val root by lazy { selectedArchive(context) }
+
+ @Before
+ fun preparePhoneDestination() {
+ scenario.files.map { it.receivedMeasurementName }.distinct().forEach { name ->
+ root.findFile(name)?.delete()
+ }
+ scenario.transferredFiles.forEach { fixture ->
+ fixture.existingPhoneContent?.let { existingContent ->
+ val folder = root.findFile(fixture.receivedMeasurementName)
+ ?: checkNotNull(root.createDirectory(fixture.receivedMeasurementName))
+ val file = checkNotNull(folder.createFile("application/octet-stream", fixture.fileName))
+ checkNotNull(context.contentResolver.openOutputStream(file.uri)).use { it.write(existingContent) }
+ }
+ }
+ }
+
+ @Test
+ fun givenPairedEmulatorsWhenWearSendsFilesThenPhoneReceivesExactBytes() {
+ val dependencies = EntryPointAccessors.fromApplication(context, SyncTestEntryPoint::class.java)
+ val viewModel = WatchSyncViewModel(
+ dependencies.sync(), dependencies.commands(), dependencies.storage(), kotlinx.coroutines.Dispatchers.IO,
+ dependencies.transfers(), dependencies.destination(),
+ )
+ InstrumentationRegistry.getInstrumentation().runOnMainSync { viewModel.accept(WatchSyncIntent.COPY) }
+ Log.i(LOG_TAG, "READY_FOR_WEAR_TRANSFER")
+
+ scenario.transferredFiles.forEach { fixture ->
+ assertTrue("Timed out waiting for ${fixture.fileName}", waitFor(fixture))
+ val received = checkNotNull(receivedFile(fixture))
+ assertArrayEquals("Wrong bytes in $received", fixture.content, received.readBytes(context))
+ }
+ scenario.files.filterNot(WearSyncFileFixture::shouldTransfer).forEach { fixture ->
+ assertTrue("Unsupported file was transferred: ${receivedFile(fixture)}", receivedFile(fixture) == null)
+ }
+ }
+
+ private fun receivedFile(fixture: WearSyncFileFixture): DocumentFile? =
+ root.findFile(fixture.receivedMeasurementName)?.findFile(fixture.fileName)
+
+ private fun waitFor(fixture: WearSyncFileFixture): Boolean {
+ repeat(MAX_ATTEMPTS) {
+ val received = receivedFile(fixture)
+ if (received?.isFile == true && received.readBytes(context).contentEquals(fixture.content)) return true
+ Thread.sleep(POLL_INTERVAL_MILLIS)
+ }
+ return false
+ }
+
+ private companion object {
+ const val LOG_TAG = "SensorBoxEmulatorTest"
+ const val POLL_INTERVAL_MILLIS = 500L
+ const val MAX_ATTEMPTS = 120
+ }
+}
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..70cfeda
--- /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.documentfile.provider.DocumentFile
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.gms.tasks.Tasks
+import com.google.android.gms.wearable.MessageClient
+import com.google.android.gms.wearable.Wearable
+import com.tomasrepcik.sensorbox.core.failure.AppError
+import com.tomasrepcik.sensorbox.core.failure.AppResult
+import com.tomasrepcik.sensorbox.measurements.sync.readBytes
+import com.tomasrepcik.sensorbox.measurements.sync.selectedArchive
+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.WearRecordingSettings
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason
+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.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 kotlin.time.Duration.Companion.milliseconds
+
+@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 archive = selectedArchive(context)
+ 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,
+ settings = WearRecordingSettings(0,
+ stopOnLowBattery = true,
+ useWakeLock = false,
+ gpsIntervalSeconds = 1,
+ gpsMinDistanceMeters = 0
+ ),
+ ),
+ ).getOrThrow()
+ peer.awaitAutomaticStop(sessionId)
+ peer.sync()
+ val receivedFiles = awaitReceivedFiles(archive, "WEAR_$measurementName")
+
+ // Then
+ assertMeasurementMetadata(
+ file = receivedFiles.getValue(METADATA_FILE_NAME),
+ measurementName = measurementName,
+ sensor = accelerometer,
+ )
+ assertAccelerometerSamples(receivedFiles.getValue(ACCELEROMETER_FILE_NAME))
+ val metadata = JSONObject(receivedFiles.getValue(METADATA_FILE_NAME).readBytes(context).decodeToString())
+ assertEquals(sessionId, metadata.getString("sessionId"))
+ assertEquals("watch", metadata.getString("device"))
+ } finally {
+ peer.close()
+ archive.findFile("WEAR_$measurementName")?.let { measurement ->
+ check(measurement.delete()) { "Could not remove test measurement $measurementName" }
+ }
+ }
+ }
+
+ private suspend fun awaitReceivedFiles(
+ archive: DocumentFile,
+ measurementName: String,
+ ): Map {
+ repeat(FILE_WAIT_ATTEMPTS) {
+ val files = archive.findFile(measurementName)?.listFiles().orEmpty().associateBy { checkNotNull(it.name) }
+ val metadata = files[METADATA_FILE_NAME]
+ val accelerometer = files[ACCELEROMETER_FILE_NAME]
+ if (
+ metadata?.length()?.let { length -> length > 0L } == true &&
+ accelerometer?.readBytes(context)?.decodeToString()?.lines()?.size?.let { rows -> rows >= MINIMUM_CSV_ROWS } == true
+ ) {
+ return files
+ }
+ delay(FILE_WAIT_INTERVAL_MILLIS.milliseconds)
+ }
+ throw AssertionError(
+ "Timed out waiting for transferred watch measurement $measurementName in ${archive.uri}",
+ )
+ }
+
+ private fun assertMeasurementMetadata(file: DocumentFile, measurementName: String, sensor: WearSensorInfo) {
+ val metadata = JSONObject(file.readBytes(context).decodeToString())
+ 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: DocumentFile) {
+ val rows = file.readBytes(context).decodeToString().lineSequence().filter(String::isNotBlank).toList()
+ 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 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 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)
+ }
+ }
+ fun open() {
+ Tasks.await(messageClient.addListener(listener))
+ }
+
+ fun close() {
+ Tasks.await(messageClient.removeListener(listener))
+ incomingCommands.close()
+ }
+
+ 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.milliseconds) {
+ 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.milliseconds) {
+ 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() {
+ val requestId = "sync-${System.currentTimeMillis()}"
+ sendOneWay(WearCommand.CopyWatchMeasurements(requestId)).getOrThrow()
+ val completed = awaitSyncStatus(requestId)
+ assertTrue("Watch did not finish syncing", completed.finished)
+ assertTrue("Watch could not save and remove measurements", !completed.failed)
+ val checkId = "$requestId-check"
+ sendOneWay(WearCommand.CheckWatchMeasurements(checkId)).getOrThrow()
+ val remaining = awaitSyncStatus(checkId)
+ assertTrue("Watch could not list remaining measurements", !remaining.failed)
+ assertEquals("Saved measurements must be removed from the watch", 0, remaining.measurementCount)
+ }
+
+ private suspend fun awaitSyncStatus(requestId: String): WearCommand.WatchMeasurementsStatus =
+ withTimeoutOrNull(SYNC_TIMEOUT_MILLIS.milliseconds) {
+ incomingCommands.receiveAsFlow()
+ .filterIsInstance()
+ .first { it.requestId == requestId }
+ } ?: throw AssertionError("Watch sync status did not arrive")
+
+ private suspend fun sendOneWay(command: WearCommand): AppResult =
+ sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command)
+
+ private companion object {
+ const val SYNC_TIMEOUT_MILLIS = 60_000L
+ 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 79%
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..93f88f3 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,17 @@ 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.WearRecordingSettings
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.filterIsInstance
@@ -27,6 +28,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
+import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class)
class PhoneWearDiscoveryEmulatorTest {
@@ -50,7 +52,7 @@ class PhoneWearDiscoveryEmulatorTest {
.sendMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload)
.getOrThrow()
- val catalog = withTimeout(RESPONSE_TIMEOUT_MILLIS) { response.await() }
+ val catalog = withTimeout(RESPONSE_TIMEOUT_MILLIS.milliseconds) { response.await() }
assertTrue("Wear emulator returned no sensors", catalog.sensors.isNotEmpty())
assertTrue(
"Wear emulator returned an incomplete sensor catalog",
@@ -93,6 +95,12 @@ class PhoneWearDiscoveryEmulatorTest {
folderName = "paired_sensor_permission_test",
sensorIds = listOf(catalog.sensors.first().type),
includesGps = false,
+ settings = WearRecordingSettings(0,
+ stopOnLowBattery = true,
+ useWakeLock = false,
+ gpsIntervalSeconds = 1,
+ gpsMinDistanceMeters = 0
+ ),
),
)
connection.sendMessage(
@@ -141,6 +149,12 @@ class PhoneWearDiscoveryEmulatorTest {
folderName = "paired_gps_permission_test",
sensorIds = emptyList(),
includesGps = true,
+ settings = WearRecordingSettings(0,
+ stopOnLowBattery = true,
+ useWakeLock = false,
+ gpsIntervalSeconds = 1,
+ gpsMinDistanceMeters = 0
+ ),
),
),
).getOrThrow(),
@@ -165,7 +179,7 @@ class PhoneWearDiscoveryEmulatorTest {
private suspend inline fun awaitCommand(
commands: Channel,
crossinline matches: (T) -> Boolean = { true },
- ): T = withTimeout(RESPONSE_TIMEOUT_MILLIS) {
+ ): T = withTimeout(RESPONSE_TIMEOUT_MILLIS.milliseconds) {
commands.receiveAsFlow().filterIsInstance().first { matches(it) }
}
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/presentation/main/MeasurementBrowserScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt
deleted file mode 100644
index 2d8c61b..0000000
--- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.tomasrepcik.sensorbox.presentation.main
-
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.hasScrollAction
-import androidx.compose.ui.test.hasText
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performScrollToNode
-import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary
-import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample
-import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme
-import org.junit.Assert.assertEquals
-import org.junit.Rule
-import org.junit.Test
-
-class MeasurementBrowserScreenTest {
- @get:Rule
- val composeRule = createComposeRule()
-
- @Test
- fun givenArchiveWhenMeasurementIsTappedThenItsIdentifierIsSelected() {
- var selectedId: String? = null
- composeRule.setContent {
- SensorBoxTheme {
- MeasurementsScreen(
- state = MeasurementBrowserState(measurements = listOf(summary)),
- onIntent = { intent ->
- if (intent is MeasurementBrowserIntent.OpenMeasurementDetails) {
- selectedId = intent.measurementId
- }
- },
- onBack = {},
- )
- }
- }
-
- composeRule.onNodeWithText("Morning walk").assertIsDisplayed().performClick()
- composeRule.runOnIdle { assertEquals(summary.id, selectedId) }
- }
-
- @Test
- fun givenMeasurementDetailsThenAllMetadataAndFilesCanBeInspected() {
- var selectedFile: String? = null
- composeRule.setContent {
- SensorBoxTheme {
- MeasurementDetailsScreen(
- state = MeasurementBrowserState(selectedMeasurement = details),
- onIntent = { intent ->
- if (intent is MeasurementBrowserIntent.OpenMeasurementFile) selectedFile = intent.fileId
- },
- onBack = {},
- )
- }
- }
-
- composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Pixel fixture"))
- composeRule.onNodeWithText("Pixel fixture").assertIsDisplayed()
- composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Accelerometer"))
- composeRule.onNodeWithText("Accelerometer").assertIsDisplayed().performClick()
- composeRule.runOnIdle { assertEquals(sensorFile.id, selectedFile) }
- }
-
- @Test
- fun givenSensorSamplesThenTheirSeriesAndChartSummaryAreShown() {
- var chartIntent: MeasurementBrowserIntent? = null
- showFile(
- sensorFile,
- MeasurementFileContent.SensorSeries(
- columns = listOf("x", "y", "z"),
- samples = listOf(
- SensorSeriesSample(1, listOf(0.1, 0.2, 0.3)),
- SensorSeriesSample(2, listOf(0.4, 0.5, 0.6)),
- ),
- truncated = false,
- ),
- onIntent = { chartIntent = it },
- )
-
- composeRule.onNodeWithText("2 chart samples").assertIsDisplayed()
- composeRule.onNodeWithText("Sensor details").assertIsDisplayed()
- 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)
- }
- }
-
- @Test
- fun givenGpsSamplesThenCoordinatesKeepNavigationPrecision() {
- showFile(
- gpsFile,
- MeasurementFileContent.GpsCoordinates(
- listOf(GpsCoordinate(1_725_000_000_000, 48.148596, 17.107748, 140.0, 3.0, null, null, "gps")),
- truncated = false,
- ),
- )
-
- composeRule.onNodeWithText("Coordinate 1").assertIsDisplayed()
- composeRule.onNodeWithText("48.148596 , 17.107748").assertIsDisplayed()
- }
-
- private fun showFile(
- file: MeasurementFileSummary,
- content: MeasurementFileContent,
- onIntent: (MeasurementBrowserIntent) -> Unit = {},
- ) {
- composeRule.setContent {
- SensorBoxTheme {
- MeasurementFileScreen(
- state = MeasurementBrowserState(
- selectedMeasurement = details,
- selectedFile = file,
- selectedFileContent = content,
- ),
- onIntent = onIntent,
- onBack = {},
- )
- }
- }
- }
-
- private companion object {
- val summary = MeasurementSummary("session-1", "Morning walk", 1_725_000_000_000, "2026-08-25", 2)
- val sensorFile = MeasurementFileSummary("accelerometer.csv", "Accelerometer", MeasurementFileKind.SENSOR, 256)
- val gpsFile = MeasurementFileSummary("gps.csv", "Gps", MeasurementFileKind.GPS, 128)
- val details = MeasurementDetails(
- summary,
- listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")),
- listOf(sensorFile, gpsFile),
- sensorMetadataByFile = mapOf(
- sensorFile.id to listOf(
- MeasurementMetadataEntry("sensor", "Bosch accelerometer"),
- MeasurementMetadataEntry("writtenSamples", "2"),
- ),
- ),
- )
- }
-}
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 93%
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..2590ff0 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
@@ -240,16 +240,6 @@ internal class RecordingEmulatorFixture(private val context: Context) {
)
}
- private fun rows(scenario: RecordingScenario, sensor: RecordedSensor): List {
- val output = File(measurementDirectory(scenario.name), sensor.fileName)
- repeat(FILE_WAIT_ATTEMPTS) {
- val rows = output.takeIf(File::isFile)?.readLines().orEmpty()
- if (rows.isNotEmpty()) return rows
- Thread.sleep(FILE_WAIT_INTERVAL_MILLIS)
- }
- return output.takeIf(File::isFile)?.readLines().orEmpty()
- }
-
private fun awaitMetadata(measurementName: String): JSONObject {
val output = File(measurementDirectory(measurementName), METADATA_FILE)
repeat(FILE_WAIT_ATTEMPTS) {
@@ -289,12 +279,6 @@ internal class RecordingEmulatorFixture(private val context: Context) {
fixture.annotate(text)
}
- fun assertOnlyHeaders(): ActiveRecording = apply {
- scenario.sensors.forEach { sensor ->
- assertEquals(listOf(sensor.header), fixture.rows(scenario, sensor))
- }
- }
-
fun stop(): RecordingOutput {
fixture.stopAnyRecording()
return fixture.complete(scenario, scheduledStartMillis, expectedRecordedTypes)
@@ -325,14 +309,6 @@ internal class RecordingEmulatorFixture(private val context: Context) {
if (scenario.significantMotion) assertHeader(files.getValue(SIGNIFICANT_MOTION_FILE), SIGNIFICANT_HEADER)
}
- fun assertCancelledBeforeStart(): RecordingOutput = apply {
- assertSessionMetadata()
- assertExactFiles()
- scenario.sensors.forEach { sensor ->
- assertEquals(listOf(sensor.header), files.getValue(sensor.fileName).readLines())
- }
- }
-
fun assertAnnotation(expected: String): RecordingOutput = apply {
val annotations = metadata.getJSONArray("annotations")
assertEquals(1, annotations.length())
@@ -422,7 +398,7 @@ internal class RecordingEmulatorFixture(private val context: Context) {
Manifest.permission.POST_NOTIFICATIONS,
)
const val METADATA_FILE = "extra.json"
- const val GPS_FILE = "gps.csv"
+ const val GPS_FILE = "GPS.csv"
const val GPS_HEADER = "time_millis;latitude;longitude;altitude;accuracy;speed;bearing;provider"
const val GPS_COLUMN_COUNT = 8
const val ACTIVITY_UPDATES_FILE = "activity_updates.csv"
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/debug/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTestEntryPoint.kt b/app/src/debug/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTestEntryPoint.kt
new file mode 100644
index 0000000..71f5c51
--- /dev/null
+++ b/app/src/debug/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTestEntryPoint.kt
@@ -0,0 +1,18 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.tomasrepcik.sensorbox.core.storage.DocumentStorage
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface SyncTestEntryPoint {
+ fun sync(): WatchSyncRepo
+ fun commands(): SendWearCommandUseCase
+ fun storage(): DocumentStorage
+ fun transfers(): WearFileTransferClient
+ fun destination(): WatchFileDestination
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 62e169e..b0c016f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,9 +9,6 @@
-
-
-
@@ -22,18 +19,18 @@
android:required="true" />
@@ -44,7 +41,7 @@
@@ -57,7 +54,7 @@
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt b/app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt
deleted file mode 100644
index a13409b..0000000
--- a/app/src/main/java/com/tomasrepcik/sensorbox/MsgListener.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.tomasrepcik.sensorbox
-
-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 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 MsgListener : WearableListenerService() {
- @Inject
- lateinit var messageDispatcher: PhoneWatchMessageDispatcher
-
- @Inject
- lateinit var receiveWearFile: ReceiveWatchFileUseCase
-
- private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
-
- override fun onMessageReceived(messageEvent: MessageEvent) {
- serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) }
- }
-
- override fun onChannelOpened(channel: ChannelClient.Channel) {
- serviceScope.launch { receiveWearFile(channel) }
- }
-
- override fun onDestroy() {
- serviceScope.cancel()
- super.onDestroy()
- }
-}
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 55%
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..ea4b2e6 100644
--- a/app/src/main/java/com/tomasrepcik/sensorbox/di/CoroutineModule.kt
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/bootstrap/CoroutineModule.kt
@@ -1,20 +1,32 @@
-package com.tomasrepcik.sensorbox.di
+package com.tomasrepcik.sensorbox.bootstrap
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
+import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ApplicationScope
+
@Module
@InstallIn(SingletonComponent::class)
object CoroutineModule {
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
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 57%
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..4c94f2e 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,9 @@
-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 com.tomasrepcik.sensorbox.recording.sources.RecordingSourceAvailability
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -18,9 +19,13 @@ class SensorBoxApp : Application() {
@Inject
lateinit var recordingSessionObserver: PhoneRecordingSessionObserver
+ @Inject
+ lateinit var recordingSourceAvailability: RecordingSourceAvailability
+
override fun onCreate() {
super.onCreate()
diagnostics.installUncaughtExceptionHandler()
recordingSessionObserver.start()
+ recordingSourceAvailability.start()
}
}
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 71%
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..0c0a2fc 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,13 +1,8 @@
-package com.tomasrepcik.sensorbox.presentation.main
+package com.tomasrepcik.sensorbox.design
import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
@@ -15,7 +10,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -75,9 +69,11 @@ fun SensorBoxSecondaryButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
+ enabled = enabled,
modifier = modifier.height(48.dp),
shape = MaterialTheme.shapes.medium,
border = BorderStroke(1.dp, contentColor.copy(alpha = 0.72f)),
@@ -86,24 +82,3 @@ fun SensorBoxSecondaryButton(
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
-
-@Composable
-fun SensorBoxBottomButton(
- title: String,
- description: String,
- buttonLabel: String,
- enabled: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface) {
- Column(Modifier.padding(horizontal = 20.dp, vertical = 14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(title, style = MaterialTheme.typography.labelLarge)
- Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- Spacer(Modifier.height(10.dp))
- SensorBoxPrimaryButton(buttonLabel, onClick, Modifier.fillMaxWidth(), enabled)
- }
- }
-}
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/app/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 app/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/app/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/domain/sync/ReceiveWatchFileUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/ReceiveWatchFileUseCase.kt
deleted file mode 100644
index e0d5172..0000000
--- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/ReceiveWatchFileUseCase.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.tomasrepcik.sensorbox.domain.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 javax.inject.Inject
-
-class ReceiveWatchFileUseCase @Inject constructor(
- private val transferClient: WearFileTransferClient,
- private val destination: WatchFileDestination,
-) {
- suspend operator fun invoke(channel: ChannelClient.Channel): AppResult =
- WearFilePathCodec.decode(channel.path).suspendFlatMap { metadata ->
- destination.isReady().flatMap { ready ->
- if (ready) {
- AppResult.success(Unit)
- } else {
- AppResult.failure(
- AppError(AppErrorCode.STORAGE, "Receive watch file"),
- )
- }
- }.suspendFlatMap {
- transferClient.receive(channel) { input -> destination.copy(metadata, input) }
- }
- }
-}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt
deleted file mode 100644
index cd02ee0..0000000
--- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sync/WatchFileDestination.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.tomasrepcik.sensorbox.domain.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.storage.DocumentStorage
-import com.tomasrepcik.sensorbox.wearoslib.files.WearFileMetadata
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.io.File
-import java.io.InputStream
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class WatchFileDestination @Inject constructor(
- @ApplicationContext private val context: Context,
- private val documentStorage: DocumentStorage,
-) {
- fun isReady(): AppResult = hasConfiguredDirectory().map { configured -> configured || isDebugBuild() }
-
- fun copy(metadata: WearFileMetadata, input: InputStream): AppResult {
- val measurementName = "WEAR_${metadata.measurementName}"
- val configuredResult = hasConfiguredDirectory()
- val configured = configuredResult.getOrNull() ?: return AppResult.failure(
- checkNotNull(configuredResult.errorOrNull()),
- )
- return if (configured) {
- copyToConfiguredDirectory(measurementName, metadata.fileName, input)
- } else {
- copyToDebugDirectory(measurementName, metadata.fileName, input)
- }
- }
-
- private fun copyToConfiguredDirectory(
- measurementName: String,
- fileName: String,
- input: InputStream,
- ): AppResult = documentStorage.copyToMeasurement(
- input = input,
- measurementName = measurementName,
- fileName = fileName,
- mimeType = mimeType(fileName),
- )
-
- private fun copyToDebugDirectory(measurementName: String, fileName: String, input: InputStream): AppResult {
- if (!isDebugBuild()) return AppResult.failure(AppError(AppErrorCode.STORAGE, "Copy debug watch file"))
- return appResult(AppErrorCode.STORAGE, "Prepare debug watch directory") {
- val directory = File(context.filesDir, "$APP_DIRECTORY/$measurementName")
- directory to (directory.isDirectory || directory.mkdirs())
- }.flatMap { (directory, ready) ->
- if (!ready) {
- AppResult.failure(AppError(AppErrorCode.STORAGE, "Prepare debug watch directory"))
- } else {
- appResult(AppErrorCode.STORAGE, "Copy debug watch file") {
- File(directory, fileName).outputStream().use(input::copyTo)
- Unit
- }
- }
- }
- }
-
- private fun hasConfiguredDirectory(): AppResult = documentStorage.hasConfiguredDirectory()
-
- private fun isDebugBuild(): Boolean = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
-
- private fun mimeType(fileName: String): String = when (fileName.substringAfterLast('.').lowercase()) {
- "csv" -> "text/csv"
- "json" -> "application/json"
- "txt" -> "text/plain"
- else -> "application/octet-stream"
- }
-
- private companion object {
- const val APP_DIRECTORY = "SensorBox"
- }
-}
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/measurements/components/MeasurementsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementsComponents.kt
new file mode 100644
index 0000000..08af266
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/components/MeasurementsComponents.kt
@@ -0,0 +1,126 @@
+package com.tomasrepcik.sensorbox.measurements.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.painterResource
+import androidx.compose.ui.res.pluralStringResource
+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.measurements.list.MeasurementsIntent
+import com.tomasrepcik.sensorbox.measurements.list.MeasurementsState
+import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary
+
+internal fun LazyListScope.measurementArchiveItems(state: MeasurementsState, onIntent: (MeasurementsIntent) -> Unit) {
+ when {
+ state.isLoading && state.measurements.isEmpty() -> item {
+ MeasurementLoading(Modifier.fillParentMaxHeight())
+ }
+
+ state.errorCode != null -> item { ArchiveError() }
+
+ state.measurements.isEmpty() -> item { EmptyArchive() }
+
+ else -> state.recordings.forEach { recording ->
+ item {
+ RecordingCard(recording, onIntent)
+ }
+ }
+ }
+ if (state.isLoading && state.measurements.isNotEmpty()) item { MeasurementLoading() }
+}
+
+@Composable
+private fun RecordingCard(recording: List, onIntent: (MeasurementsIntent) -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
+ Text(
+ recording.first().recordingName,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (recording.any { it.device == "phone" } && recording.any { it.device == "watch" }) {
+ Text(
+ stringResource(R.string.measurement_pair),
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ recording.forEach { measurement ->
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ MeasurementRow(measurement) { onIntent(MeasurementsIntent.OpenDetails(measurement.id)) }
+ }
+ }
+}
+
+@Composable
+private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painterResource(
+ when (measurement.device) {
+ "phone" -> R.drawable.ic_measurement_phone
+ "watch" -> R.drawable.ic_sync_watch
+ else -> R.drawable.ic_baseline_folder
+ },
+ ),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(
+ when (measurement.device) {
+ "phone" -> stringResource(R.string.measurement_phone)
+ "watch" -> stringResource(R.string.measurement_watch)
+ else -> measurement.name
+ },
+ style = MaterialTheme.typography.titleSmall,
+ )
+ measurement.recordedAtText?.let { recordedAt ->
+ Text(
+ recordedAt,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Text(
+ pluralStringResource(R.plurals.measurement_file_count, measurement.fileCount, measurement.fileCount),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Icon(
+ painterResource(R.drawable.ic_measurement_open),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
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..c67f5d1
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModel.kt
@@ -0,0 +1,66 @@
+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.Job
+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)
+ private var activeMeasurementId: String? = null
+ private var loadJob: Job? = null
+
+ 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) {
+ if (activeMeasurementId == measurementId) return
+ activeMeasurementId = measurementId
+ loadJob?.cancel()
+ loadJob = 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..93927a5
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsContract.kt
@@ -0,0 +1,44 @@
+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,
+) {
+ val recordings: List>
+ get() = measurements.groupBy { measurement ->
+ measurement.sessionId?.let { "session:$it" } ?: "measurement:${measurement.id}"
+ }.values.map { group -> group.sortedBy { it.device == "watch" } }
+}
+
+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..72d3ebf
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/list/MeasurementsRoot.kt
@@ -0,0 +1,29 @@
+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.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+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)
+ }
+ }
+ }
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { 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 80%
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..072e915 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,10 +23,13 @@ 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)
+ val labels = content.columns.map { column ->
+ if (isActivityColumn(column)) stringResource(activityNameResource(column)) else column
+ }
+ Text(labels.joinToString(), color = MaterialTheme.colorScheme.onSurfaceVariant)
if (content.truncated) {
Text(stringResource(R.string.chart_sampled_notice), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -35,7 +40,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..9a325be
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/MeasurementPreviewScreen.kt
@@ -0,0 +1,109 @@
+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) } }
+ }
+ if (recordedEventKind(content.columns) != null) {
+ recordedEventItems(content)
+ } else {
+ 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/measurements/preview/RecordedEventKind.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEventKind.kt
new file mode 100644
index 0000000..0a5260b
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEventKind.kt
@@ -0,0 +1,42 @@
+package com.tomasrepcik.sensorbox.measurements.preview
+
+import androidx.annotation.StringRes
+import com.google.android.gms.location.DetectedActivity
+import com.tomasrepcik.sensorbox.R
+
+internal enum class RecordedEventKind { STEP, MOTION, ACTIVITY_TRANSITION }
+
+internal fun recordedEventKind(columns: List): RecordedEventKind? = when {
+ columns == listOf("step") -> RecordedEventKind.STEP
+ columns == listOf("event") -> RecordedEventKind.MOTION
+ columns.toSet() == setOf("activity", "enter_exit") -> RecordedEventKind.ACTIVITY_TRANSITION
+ else -> null
+}
+
+internal fun isActivityColumn(column: String): Boolean = column in activityNames
+
+@StringRes
+internal fun activityNameResource(activity: String): Int = activityNames[activity] ?: R.string.preview_activity_unknown
+
+@StringRes
+internal fun activityNameResource(activity: Double?): Int = when (activity) {
+ DetectedActivity.STILL.toDouble() -> R.string.preview_activity_still
+ DetectedActivity.ON_FOOT.toDouble() -> R.string.preview_activity_on_foot
+ DetectedActivity.WALKING.toDouble() -> R.string.preview_activity_walking
+ DetectedActivity.RUNNING.toDouble() -> R.string.preview_activity_running
+ DetectedActivity.IN_VEHICLE.toDouble() -> R.string.preview_activity_vehicle
+ DetectedActivity.ON_BICYCLE.toDouble() -> R.string.preview_activity_bike
+ DetectedActivity.TILTING.toDouble() -> R.string.preview_activity_tilting
+ else -> R.string.preview_activity_unknown
+}
+
+private val activityNames = mapOf(
+ "still" to R.string.preview_activity_still,
+ "on_foot" to R.string.preview_activity_on_foot,
+ "walking" to R.string.preview_activity_walking,
+ "running" to R.string.preview_activity_running,
+ "vehicle" to R.string.preview_activity_vehicle,
+ "bike" to R.string.preview_activity_bike,
+ "unknown" to R.string.preview_activity_unknown,
+ "tilting" to R.string.preview_activity_tilting,
+)
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEvents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEvents.kt
new file mode 100644
index 0000000..49f7223
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEvents.kt
@@ -0,0 +1,90 @@
+package com.tomasrepcik.sensorbox.measurements.preview
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+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.unit.dp
+import com.google.android.gms.location.ActivityTransition
+import com.tomasrepcik.sensorbox.R
+import com.tomasrepcik.sensorbox.design.SensorBoxPanel
+import com.tomasrepcik.sensorbox.measurements.storage.MeasurementFileContent
+import com.tomasrepcik.sensorbox.measurements.storage.SensorSeriesSample
+import java.text.SimpleDateFormat
+import java.util.Date
+import androidx.compose.ui.platform.LocalLocale
+
+internal fun LazyListScope.recordedEventItems(content: MeasurementFileContent.SensorSeries) {
+ val kind = checkNotNull(recordedEventKind(content.columns))
+ item { RecordedEventSummary(content) }
+ itemsIndexed(content.samples) { index, sample ->
+ when (kind) {
+ RecordedEventKind.ACTIVITY_TRANSITION -> ActivityTransitionRow(content.columns, sample)
+ RecordedEventKind.STEP -> RecordedEventRow(sample, index, isStep = true)
+ RecordedEventKind.MOTION -> RecordedEventRow(sample, index, isStep = false)
+ }
+ }
+}
+
+@Composable
+internal fun RecordedEventSummary(content: MeasurementFileContent.SensorSeries) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ val summary = when {
+ content.samples.isEmpty() -> stringResource(R.string.preview_no_events)
+ content.truncated -> stringResource(R.string.preview_shown_event_count, content.samples.size)
+ else -> stringResource(R.string.preview_event_count, content.samples.size)
+ }
+ Text(summary, style = MaterialTheme.typography.titleMedium)
+ if (content.truncated) Text(stringResource(R.string.preview_sampled_events_notice))
+ }
+}
+
+@Composable
+internal fun RecordedEventRow(sample: SensorSeriesSample, index: Int, isStep: Boolean) {
+ EventPanel(sample.timestampMillis) {
+ val title = if (isStep) {
+ stringResource(R.string.preview_step_number, index + 1)
+ } else {
+ stringResource(R.string.preview_motion_detected)
+ }
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ }
+}
+
+@Composable
+private fun ActivityTransitionRow(columns: List, sample: SensorSeriesSample) {
+ val activity = sample.values.getOrNull(columns.indexOf("activity"))
+ val transition = sample.values.getOrNull(columns.indexOf("enter_exit"))
+ val activityName = stringResource(activityNameResource(activity))
+ val title = when (transition) {
+ ActivityTransition.ACTIVITY_TRANSITION_ENTER.toDouble() ->
+ stringResource(R.string.preview_activity_started, activityName)
+
+ ActivityTransition.ACTIVITY_TRANSITION_EXIT.toDouble() ->
+ stringResource(R.string.preview_activity_ended, activityName)
+
+ else -> stringResource(R.string.preview_unknown_transition, activityName, transition.toString())
+ }
+ EventPanel(sample.timestampMillis) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ }
+}
+
+@Composable
+private fun EventPanel(timestampMillis: Long, content: @Composable () -> Unit) {
+ SensorBoxPanel(Modifier.padding(vertical = 5.dp)) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", LocalLocale.current.platformLocale).format(Date(timestampMillis)),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ content()
+ }
+ }
+}
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 87%
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..9ff0d88 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
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +17,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -32,7 +34,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 +42,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)
@@ -61,7 +63,11 @@ private fun storedChartColors(): List = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary,
MaterialTheme.colorScheme.error,
- MaterialTheme.colorScheme.secondary,
+ Color(0xFF00897B),
+ Color(0xFFFB8C00),
+ Color(0xFF8E24AA),
+ Color(0xFF039BE5),
+ Color(0xFF7CB342),
)
@Composable
@@ -89,7 +95,7 @@ private fun StoredChartPlot(
viewport: StoredChartViewport,
columnCount: Int,
colors: List,
- onIntent: (MeasurementBrowserIntent) -> Unit,
+ onIntent: (MeasurementPreviewIntent) -> Unit,
) {
Canvas(
Modifier
@@ -101,7 +107,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 +115,7 @@ private fun StoredChartPlot(
}
if (pan.x != 0f) {
onIntent(
- MeasurementBrowserIntent.ShiftSensorChartTimeWindow(
+ MeasurementPreviewIntent.ShiftSensorChartTimeWindow(
visibleWindowFraction = -pan.x / chartWidth,
),
)
@@ -122,15 +128,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))
}
}
@@ -156,12 +162,17 @@ private fun StoredChartTimeSummary(viewport: StoredChartViewport) {
@Composable
private fun StoredChartLegend(columns: List, colors: List) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ FlowRow(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
columns.forEachIndexed { index, column ->
- Row {
+ Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(10.dp).background(colors[index % colors.size]))
Spacer(Modifier.size(4.dp))
- Text(column, style = MaterialTheme.typography.bodySmall)
+ val label = if (isActivityColumn(column)) stringResource(activityNameResource(column)) else column
+ Text(label, style = MaterialTheme.typography.bodySmall)
}
}
}
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..be96685 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,12 @@
-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 com.tomasrepcik.sensorbox.core.storage.BACKUP_MEASUREMENT_PREFIX
+import com.tomasrepcik.sensorbox.core.storage.PENDING_MEASUREMENT_PREFIX
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -23,6 +17,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) :
@@ -30,7 +26,13 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
override suspend fun loadMeasurements(): AppResult> = withContext(Dispatchers.IO) {
suspendAppResult(AppErrorCode.STORAGE, "List saved measurements") {
selectedMeasurementsDirectory().listFiles()
- .filter(DocumentFile::isDirectory)
+ .filter {
+ it.isDirectory &&
+ !it.name.orEmpty().startsWith(
+ PENDING_MEASUREMENT_PREFIX,
+ ) &&
+ !it.name.orEmpty().startsWith(BACKUP_MEASUREMENT_PREFIX)
+ }
.map(::createMeasurementSummary)
.sortedWith(
compareByDescending { it.recordedAtMillis ?: Long.MIN_VALUE }
@@ -59,27 +61,30 @@ 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
- .filter { it.isReadPermission }
+ .filter { it.isReadPermission && it.isWritePermission }
.maxByOrNull { it.persistedTime }
?: throw IllegalStateException("Recording archive is not configured")
return DocumentFile.fromTreeUri(context, permission.uri)
@@ -96,6 +101,9 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
return MeasurementSummary(
id = checkNotNull(directory.name),
name = directory.name.orEmpty(),
+ sessionId = json?.get("sessionId")?.asPrimitive()?.contentOrNull?.takeIf(String::isNotBlank),
+ device = json?.get("device")?.asPrimitive()?.contentOrNull,
+ recordingName = json?.get("folder")?.asPrimitive()?.contentOrNull ?: directory.name.orEmpty(),
recordedAtMillis = json?.get("millis")?.asPrimitive()?.longOrNull,
recordedAtText = json?.get("date")?.asPrimitive()?.contentOrNull,
fileCount = directory.listFiles().count { it.isFile && it.name != METADATA_FILE },
@@ -109,60 +117,78 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva
name.endsWith(CSV_EXTENSION, ignoreCase = true) -> MeasurementFileKind.SENSOR
else -> MeasurementFileKind.TEXT
}
- return MeasurementFileSummary(name, formatFileDisplayName(name), kind, document.length())
+ val displayName = if (kind == MeasurementFileKind.GPS) "GPS" else formatFileDisplayName(name)
+ return MeasurementFileSummary(name, displayName, kind, document.length())
}
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)
- }
-
- private fun parseSensorSample(
- line: String,
- timestampIndex: Int,
- valueIndexes: List,
- sensorTimestamp: Boolean,
- anchor: SensorTimeAnchor?,
- ): SensorSeriesSample? {
- val fields = line.split(DELIMITER)
- val rawTimestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null
- val timestamp = if (sensorTimestamp) {
- anchor?.unixMillis?.plus((rawTimestamp - anchor.elapsedRealtimeNanos) / 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) }
+ return columns
}
private fun readSensorTimeAnchor(directory: DocumentFile): SensorTimeAnchor? {
@@ -182,10 +208,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 +231,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 +259,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,29 +278,61 @@ 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"
+ const val GPS_FILE = "GPS.csv"
const val CSV_EXTENSION = ".csv"
const val DELIMITER = ';'
const val MAX_CHART_SAMPLES = 2_000
const val MAX_GPS_COORDINATES = 10_000
const val MAX_TEXT_CHARACTERS = 100_000
const val TEXT_BUFFER_SIZE = 4_096
- const val SAMPLE_REPLACEMENT_INTERVAL = 100
- const val NANOS_PER_MILLISECOND = 1_000_000L
- val NON_VALUE_COLUMNS = setOf("t_sensor", "accuracy", "provider")
val JSON = Json { ignoreUnknownKeys = true }
}
+}
+
+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)
+ }
+ }
+}
- private data class SensorTimeAnchor(val unixMillis: Long, val elapsedRealtimeNanos: Long)
+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 72%
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..795805d 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,12 +1,13 @@
-package com.tomasrepcik.sensorbox.data.measurements
+package com.tomasrepcik.sensorbox.measurements.storage
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry
+import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorSpec
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
internal object MeasurementMetadataParser {
@@ -24,16 +25,20 @@ internal object MeasurementMetadataParser {
private fun sensorMetadata(root: JsonObject): Map> {
val ranges = root[RANGES_KEY] as? JsonArray ?: JsonArray(emptyList())
val sensorFiles = root[SENSOR_FILES_KEY] as? JsonArray ?: JsonArray(emptyList())
- return sensorFiles.mapIndexedNotNull { index, element ->
- val file = element as? JsonObject ?: return@mapIndexedNotNull null
- val fileName = (file[FILE_NAME_KEY] as? JsonPrimitive)?.contentOrNull
- ?: return@mapIndexedNotNull null
- val details = buildList {
- ranges.getOrNull(index)?.let { addAll(flatten(it)) }
- addAll(flatten(JsonObject(file.filterKeys { key -> key != FILE_NAME_KEY })))
- }
- fileName to details
+ val rangesByFile = ranges.mapNotNull { element ->
+ val range = element as? JsonObject ?: return@mapNotNull null
+ val type = (range["type"] as? JsonPrimitive)?.intOrNull ?: return@mapNotNull null
+ val name = SensorSpec.fromType(type)?.fileName ?: return@mapNotNull null
+ name to flatten(range)
}.toMap()
+ val detailsByFile = rangesByFile.toMutableMap()
+ sensorFiles.forEach { element ->
+ val file = element as? JsonObject ?: return@forEach
+ val fileName = (file[FILE_NAME_KEY] as? JsonPrimitive)?.contentOrNull ?: return@forEach
+ detailsByFile[fileName] = rangesByFile[fileName].orEmpty() +
+ flatten(JsonObject(file.filterKeys { key -> key != FILE_NAME_KEY }))
+ }
+ return detailsByFile
}
private fun flatten(element: JsonElement): List = buildList {
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 80%
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..97ab839 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(
@@ -16,6 +20,9 @@ data class MeasurementSummary(
val recordedAtMillis: Long?,
val recordedAtText: String?,
val fileCount: Int,
+ val sessionId: String? = null,
+ val device: String? = null,
+ val recordingName: String = name,
)
data class MeasurementDetails(
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParser.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParser.kt
new file mode 100644
index 0000000..258563b
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParser.kt
@@ -0,0 +1,53 @@
+package com.tomasrepcik.sensorbox.measurements.storage
+
+internal fun sensorSeriesFormat(header: List): SensorSeriesFormat {
+ val unixTimestampIndex = header.indexOf("t_unix").takeIf { it >= 0 }
+ val sensorTimestampIndex = header.indexOfFirst { it == "t_sensor" || it == "t_nanos" }
+ .takeIf { it >= 0 }
+ val elapsedTimestampIndex = header.indexOf("t_elapsed").takeIf { it >= 0 }
+ val timestampIndex = unixTimestampIndex ?: sensorTimestampIndex ?: elapsedTimestampIndex ?: 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 = unixTimestampIndex == null && sensorTimestampIndex != null,
+ usesElapsedTimestamp = unixTimestampIndex == null && elapsedTimestampIndex != null,
+ )
+}
+
+internal fun parseSensorSample(
+ line: String,
+ format: SensorSeriesFormat,
+ anchor: SensorTimeAnchor?,
+): SensorSeriesSample? {
+ val fields = line.split(DELIMITER)
+ 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)
+ } else if (format.usesElapsedTimestamp) {
+ anchor?.unixMillis?.plus(rawTimestamp - anchor.elapsedRealtimeNanos / NANOS_PER_MILLISECOND)
+ ?: rawTimestamp
+ } else {
+ rawTimestamp
+ }
+ val values = format.valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() }
+ return values.takeIf { it.size == format.valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) }
+}
+
+internal data class SensorTimeAnchor(val unixMillis: Long, val elapsedRealtimeNanos: Long)
+
+internal data class SensorSeriesFormat(
+ val columns: List,
+ val timestampIndex: Int,
+ val valueIndexes: List,
+ val usesSensorTimestamp: Boolean,
+ val usesElapsedTimestamp: Boolean,
+)
+
+private const val DELIMITER = ';'
+private const val NANOS_PER_MILLISECOND = 1_000_000L
+private val NON_VALUE_COLUMNS = setOf("t_unix", "t_sensor", "t_nanos", "t_elapsed", "accuracy", "provider")
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt
new file mode 100644
index 0000000..def55c3
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/ReceiveWatchFileUseCase.kt
@@ -0,0 +1,34 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.google.android.gms.wearable.ChannelClient
+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.sync.WearFilePathCodec
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient
+import javax.inject.Inject
+
+class ReceiveWatchFileUseCase @Inject constructor(
+ private val transferClient: WearFileTransferClient,
+ private val destination: WatchFileDestination,
+ private val watchSync: WatchSyncRepo,
+) {
+ suspend operator fun invoke(channel: ChannelClient.Channel): AppResult {
+ val metadata = WearFilePathCodec.decode(channel.path).getOrNull()
+ if (metadata == null || !watchSync.accepts(metadata.requestId)) {
+ transferClient.reject(channel)
+ return AppResult.failure(AppError(AppErrorCode.CONFLICT, "Reject expired watch transfer"))
+ }
+ val result = transferClient.receive(channel) { input -> destination.copy(metadata, input) }
+ if (result.isSuccess) {
+ watchSync.fileSaved(metadata)
+ } else {
+ watchSync.fail(
+ "Could not save a watch measurement. Check the recording folder and retry.",
+ metadata.requestId,
+ )
+ destination.discard(metadata.requestId)
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt
new file mode 100644
index 0000000..5f0ba91
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestination.kt
@@ -0,0 +1,56 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+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.storage.DocumentStorage
+import com.tomasrepcik.sensorbox.core.storage.MeasurementImport
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata
+import java.io.InputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WatchFileDestination @Inject constructor(
+ private val documentStorage: DocumentStorage,
+ private val sync: WatchSyncRepo,
+) {
+ private val pending = mutableMapOf, MeasurementImport>()
+
+ @Synchronized
+ fun copy(metadata: WearFileMetadata, input: InputStream): AppResult =
+ appResult(AppErrorCode.STORAGE, "Receive watch measurement") {
+ check(sync.accepts(metadata.requestId)) { "Watch sync request expired" }
+ val key = metadata.requestId to metadata.measurementName
+ if (metadata.isCommit) {
+ val bytes = ByteArray(64)
+ java.io.DataInputStream(input).readFully(bytes)
+ check(input.read() == -1) { "Invalid measurement commit length" }
+ val fingerprint = bytes.decodeToString()
+ check(fingerprint.matches(Regex("[a-f0-9]{64}"))) { "Invalid measurement fingerprint" }
+ val staged = checkNotNull(pending[key]) { "No staged watch measurement" }
+ sync.syncLock.whileActive(metadata.requestId) {
+ check(sync.accepts(metadata.requestId))
+ staged.commit(fingerprint).getOrThrow()
+ }
+ pending.remove(key)
+ } else {
+ val staged = pending.getOrPut(key) {
+ documentStorage.beginMeasurementImport("WEAR_${metadata.measurementName}").getOrThrow()
+ }
+ staged.copy(metadata.fileName, mimeType(metadata.fileName), input).getOrThrow()
+ }
+ }
+
+ @Synchronized
+ fun discard(requestId: String) {
+ pending.keys.filter { it.first == requestId }.forEach { key -> pending.remove(key)?.discard() }
+ }
+
+ private fun mimeType(fileName: String): String = when (fileName.substringAfterLast('.').lowercase()) {
+ "csv" -> "text/csv"
+ "json" -> "application/json"
+ "txt" -> "text/plain"
+ else -> "application/octet-stream"
+ }
+}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepo.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepo.kt
new file mode 100644
index 0000000..8f6aebc
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepo.kt
@@ -0,0 +1,95 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+import javax.inject.Singleton
+
+internal enum class WatchSyncStatus { UNKNOWN, CHECKING, AVAILABLE, COPYING, COMPLETE, FAILED }
+
+internal data class WatchSyncState(
+ val status: WatchSyncStatus = WatchSyncStatus.UNKNOWN,
+ val requestId: String? = null,
+ val fileCount: Int? = null,
+ val measurementCount: Int = 0,
+ val receivedFiles: List = emptyList(),
+ val senderFinished: Boolean = false,
+ val message: String? = null,
+ val retryCopy: Boolean = false,
+) {
+ val measurementsToFetch: Int get() = if (status == WatchSyncStatus.COMPLETE) 0 else measurementCount
+ val busy: Boolean get() = status == WatchSyncStatus.CHECKING || status == WatchSyncStatus.COPYING
+}
+
+@Singleton
+class WatchSyncRepo @Inject constructor(val syncLock: MeasurementSyncLock) {
+ private val mutableState = MutableStateFlow(WatchSyncState())
+ internal val state = mutableState.asStateFlow()
+
+ internal fun begin(requestId: String, copy: Boolean) {
+ if (copy) syncLock.begin(requestId)
+ mutableState.update {
+ it.copy(
+ status = if (copy) WatchSyncStatus.COPYING else WatchSyncStatus.CHECKING,
+ requestId = requestId,
+ retryCopy = copy,
+ measurementCount = if (!copy && it.status == WatchSyncStatus.COMPLETE) 0 else it.measurementCount,
+ receivedFiles = if (copy) emptyList() else it.receivedFiles,
+ senderFinished = false,
+ message = null,
+ )
+ }
+ }
+
+ fun receive(status: WearCommand.WatchMeasurementsStatus) {
+ mutableState.update { current ->
+ if (current.requestId != status.requestId || !current.busy) return@update current
+ if (status.failed) {
+ syncLock.finish(status.requestId)
+ return@update current.copy(
+ status = WatchSyncStatus.FAILED,
+ measurementCount = status.measurementCount,
+ message = "The watch could not send its measurements. Try again.",
+ )
+ }
+ current.copy(
+ fileCount = status.fileCount,
+ measurementCount = status.measurementCount,
+ senderFinished = status.finished,
+ status = if (status.finished) WatchSyncStatus.COPYING else WatchSyncStatus.AVAILABLE,
+ ).completedIfSaved()
+ }
+ }
+
+ fun fileSaved(metadata: WearFileMetadata) {
+ mutableState.update { current ->
+ if (!accepts(metadata.requestId) || metadata.isCommit) return@update current
+ current.copy(
+ receivedFiles = (current.receivedFiles + "WEAR_${metadata.measurementName}/${metadata.fileName}")
+ .distinct(),
+ ).completedIfSaved()
+ }
+ }
+
+ fun accepts(requestId: String): Boolean = requestId.isNotBlank() &&
+ state.value.requestId == requestId && state.value.status == WatchSyncStatus.COPYING
+
+ internal fun fail(message: String, requestId: String? = state.value.requestId) {
+ mutableState.update {
+ if (it.requestId != requestId) it else it.copy(status = WatchSyncStatus.FAILED, message = message)
+ }
+ requestId?.let(syncLock::finish)
+ }
+
+ private fun WatchSyncState.completedIfSaved(): WatchSyncState =
+ if (status == WatchSyncStatus.COPYING && senderFinished && receivedFiles.size == fileCount) {
+ requestId?.let(syncLock::finish)
+ copy(status = WatchSyncStatus.COMPLETE)
+ } else {
+ this
+ }
+}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRoot.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRoot.kt
new file mode 100644
index 0000000..54c4f64
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRoot.kt
@@ -0,0 +1,198 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.tomasrepcik.sensorbox.R
+
+@Composable
+fun WatchSyncRoot(isWatchConnected: Boolean, viewModel: WatchSyncViewModel = hiltViewModel()) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ LaunchedEffect(isWatchConnected) {
+ if (isWatchConnected && !state.busy && state.status != WatchSyncStatus.FAILED) {
+ viewModel.accept(WatchSyncIntent.CHECK)
+ }
+ }
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ if (isWatchConnected && !state.busy && state.status != WatchSyncStatus.FAILED) {
+ viewModel.accept(WatchSyncIntent.CHECK)
+ }
+ }
+ WatchSyncContent(state, isWatchConnected, viewModel::accept)
+}
+
+@Composable
+internal fun WatchSyncContent(state: WatchSyncState, connected: Boolean, onIntent: (WatchSyncIntent) -> Unit) {
+ var showDetails by rememberSaveable { mutableStateOf(false) }
+ WatchSyncButton(state, connected) { showDetails = true }
+ if (showDetails) {
+ WatchSyncDialog(
+ state = state,
+ onIntent = onIntent,
+ onDismiss = { showDetails = false },
+ )
+ }
+}
+
+@Composable
+internal fun WatchSyncButton(state: WatchSyncState, connected: Boolean, onClick: () -> Unit) {
+ if (!connected && state.status != WatchSyncStatus.COPYING && state.status != WatchSyncStatus.FAILED) return
+ IconButton(onClick = onClick) {
+ BadgedBox(badge = { WatchSyncBadge(state) }) {
+ Icon(
+ painter = painterResource(R.drawable.ic_sync_watch),
+ contentDescription = watchSyncDescription(state),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun watchSyncDescription(state: WatchSyncState): String = when {
+ state.status == WatchSyncStatus.FAILED -> stringResource(R.string.watch_sync_failed)
+
+ state.status == WatchSyncStatus.COPYING -> stringResource(R.string.watch_sync_show_progress)
+
+ state.busy || state.status == WatchSyncStatus.UNKNOWN -> stringResource(R.string.watch_sync_checking)
+
+ state.measurementsToFetch == 0 -> stringResource(R.string.watch_sync_up_to_date)
+
+ else -> pluralStringResource(
+ R.plurals.watch_measurements_to_fetch,
+ state.measurementsToFetch,
+ state.measurementsToFetch,
+ )
+}
+
+@Composable
+private fun WatchSyncBadge(state: WatchSyncState) {
+ val failed = state.status == WatchSyncStatus.FAILED
+ val checking = state.busy || state.status == WatchSyncStatus.UNKNOWN
+ if (!failed && !checking && state.measurementsToFetch > 0) {
+ Badge { Text(state.measurementsToFetch.toString()) }
+ return
+ }
+ val drawable = when {
+ failed -> R.drawable.ic_watch_sync_error
+ checking -> R.drawable.ic_watch_syncing
+ else -> R.drawable.ic_watch_sync_complete
+ }
+ val color = when {
+ failed -> Color(0xFFD32F2F)
+ checking -> Color(0xFF2196F3)
+ else -> Color(0xFF2E7D32)
+ }
+ Icon(painterResource(drawable), contentDescription = null, modifier = Modifier.size(16.dp), tint = color)
+}
+
+@Composable
+private fun syncLabel(state: WatchSyncState): String = when (state.status) {
+ WatchSyncStatus.UNKNOWN -> stringResource(R.string.watch_sync_title)
+
+ WatchSyncStatus.CHECKING -> stringResource(R.string.watch_sync_checking)
+
+ WatchSyncStatus.COPYING -> stringResource(R.string.watch_sync_progress, state.receivedFiles.size)
+
+ WatchSyncStatus.COMPLETE -> stringResource(R.string.watch_sync_complete, state.receivedFiles.size)
+
+ WatchSyncStatus.FAILED -> stringResource(R.string.watch_sync_failed)
+
+ WatchSyncStatus.AVAILABLE -> if (state.measurementsToFetch == 0) {
+ stringResource(R.string.watch_sync_up_to_date)
+ } else {
+ stringResource(R.string.watch_sync_available, state.measurementsToFetch)
+ }
+}
+
+@Composable
+private fun WatchSyncDialog(state: WatchSyncState, onIntent: (WatchSyncIntent) -> Unit, onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.watch_sync_title), modifier = Modifier.weight(1f))
+ IconButton(onClick = { onIntent(WatchSyncIntent.CHECK) }, enabled = !state.busy) {
+ Icon(painterResource(R.drawable.ic_watch_syncing), stringResource(R.string.watch_sync_refresh))
+ }
+ }
+ },
+ text = {
+ Column(
+ Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(syncLabel(state))
+ WatchSyncProgress(state)
+ state.message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Text(stringResource(R.string.watch_sync_destination))
+ }
+ },
+ confirmButton = { WatchSyncConfirmButton(state, onIntent) },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Icon(painterResource(R.drawable.ic_watch_sync_error), contentDescription = null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(R.string.watch_sync_close))
+ }
+ },
+ )
+}
+
+@Composable
+private fun WatchSyncProgress(state: WatchSyncState) {
+ if (state.status != WatchSyncStatus.COPYING) return
+ val total = state.fileCount ?: 0
+ if (total > 0) {
+ LinearProgressIndicator(progress = { (state.receivedFiles.size.toFloat() / total).coerceIn(0f, 1f) })
+ Text(stringResource(R.string.watch_sync_file_progress, state.receivedFiles.size, total))
+ } else {
+ LinearProgressIndicator()
+ }
+}
+
+@Composable
+private fun WatchSyncConfirmButton(state: WatchSyncState, onIntent: (WatchSyncIntent) -> Unit) {
+ val failed = state.status == WatchSyncStatus.FAILED
+ TextButton(
+ onClick = { onIntent(if (failed) WatchSyncIntent.RETRY else WatchSyncIntent.COPY) },
+ enabled = !state.busy && (failed || state.measurementsToFetch > 0),
+ ) {
+ Icon(painterResource(R.drawable.ic_watch_syncing), contentDescription = null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(if (failed) R.string.watch_sync_retry else R.string.watch_sync_copy))
+ }
+}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModel.kt
new file mode 100644
index 0000000..06f6196
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModel.kt
@@ -0,0 +1,109 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import android.annotation.SuppressLint
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.tomasrepcik.sensorbox.bootstrap.IoDispatcher
+import com.tomasrepcik.sensorbox.core.storage.DocumentStorage
+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.sync.WearFileTransferClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+internal enum class WatchSyncIntent { CHECK, COPY, RETRY }
+
+@HiltViewModel
+class WatchSyncViewModel @Inject constructor(
+ private val repo: WatchSyncRepo,
+ private val sendCommand: SendWearCommandUseCase,
+ private val storage: DocumentStorage,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val transfers: WearFileTransferClient,
+ private val destination: WatchFileDestination,
+) : ViewModel() {
+ private var activeRequestId: String? = null
+ internal val state = repo.state
+
+ internal fun accept(intent: WatchSyncIntent) {
+ if (state.value.busy) return
+ val copy = intent == WatchSyncIntent.COPY || (intent == WatchSyncIntent.RETRY && state.value.retryCopy)
+ val requestId = UUID.randomUUID().toString()
+ activeRequestId = requestId
+ repo.begin(requestId, copy)
+ viewModelScope.launch { runRequest(requestId, copy) }
+ }
+
+ private suspend fun runRequest(requestId: String, copy: Boolean) {
+ var requested = false
+ try {
+ if (copy && !withContext(ioDispatcher) { storage.hasConfiguredDirectory().getOrNull() == true }) {
+ repo.fail("Choose a recording folder in Settings before syncing.", requestId)
+ return
+ }
+ requested = true
+ requestMeasurements(requestId, copy)
+ } finally {
+ if (copy) finishTransfer(requestId, requested)
+ }
+ }
+
+ private suspend fun finishTransfer(requestId: String, requested: Boolean) {
+ transfers.cancel(requestId)
+ withContext(NonCancellable + ioDispatcher) {
+ val completed = state.value.requestId == requestId && state.value.status == WatchSyncStatus.COMPLETE
+ if (!completed) cancelWatch(requestId, requested)
+ destination.discard(requestId)
+ }
+ }
+
+ private suspend fun cancelWatch(requestId: String, requested: Boolean) {
+ if (state.value.requestId == requestId && state.value.busy) {
+ repo.fail("Sync was interrupted. Try again.", requestId)
+ }
+ if (requested) {
+ withTimeoutOrNull(5_000L.milliseconds) {
+ sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, WearCommand.CancelWatchSync(requestId))
+ }
+ }
+ }
+
+ @SuppressLint("EmptySuperCall")
+ override fun onCleared() {
+ if (state.value.busy && state.value.requestId == activeRequestId) {
+ activeRequestId?.let(transfers::cancel)
+ repo.fail("Sync was interrupted. Open watch sync to try again.", activeRequestId)
+ }
+ super.onCleared()
+ }
+
+ private suspend fun requestMeasurements(requestId: String, copy: Boolean) {
+ val command = if (copy) {
+ WearCommand.CopyWatchMeasurements(requestId)
+ } else {
+ WearCommand.CheckWatchMeasurements(requestId)
+ }
+ val finished = withTimeoutOrNull((if (copy) COPY_TIMEOUT_MILLIS else CHECK_TIMEOUT_MILLIS).milliseconds) {
+ if (sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command).isFailure) {
+ repo.fail("Watch unavailable. Connect your watch and try again.", requestId)
+ }
+ state.first { it.requestId != requestId || !it.busy }
+ }
+ if (finished == null) repo.fail("Sync timed out. Check the watch connection and try again.", requestId)
+ }
+
+ private companion object {
+ const val CHECK_TIMEOUT_MILLIS = 15_000L
+ const val COPY_TIMEOUT_MILLIS = 300_000L
+ }
+}
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/pairedrecording/MsgListener.kt b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt
new file mode 100644
index 0000000..b6d5ed7
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt
@@ -0,0 +1,32 @@
+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.measurements.sync.ReceiveWatchFileUseCase
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearTransferWorkService
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MsgListener : WearableListenerService() {
+ @Inject
+ lateinit var messageDispatcher: PhoneWatchMessageDispatcher
+
+ @Inject
+ lateinit var receiveWearFile: ReceiveWatchFileUseCase
+
+ @Inject
+ lateinit var transfers: WearFileTransferClient
+
+ override fun onMessageReceived(messageEvent: MessageEvent) {
+ runBlocking { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) }
+ }
+
+ override fun onChannelOpened(channel: ChannelClient.Channel) {
+ WearTransferWorkService.enqueue(this) { receiveWearFile(channel) }
+ .onFailure { runBlocking { transfers.reject(channel) } }
+ }
+}
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 62%
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..4a414b6 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,22 @@
-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.measurements.sync.WatchSyncRepo
+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
@@ -25,6 +26,7 @@ class PhoneWatchCommandHandler @Inject constructor(
private val recordingResults: RecordingResultReceiver,
private val recording: PeerRecordingControl,
private val sendCommand: SendWearCommandUseCase,
+ private val watchSync: WatchSyncRepo,
) {
suspend fun handle(command: WearCommand): AppResult = when (command) {
WearCommand.LaunchPhone -> launchPhone()
@@ -41,8 +43,15 @@ class PhoneWatchCommandHandler @Inject constructor(
is WearCommand.StopRecording -> stopFromWatch(command)
+ is WearCommand.WatchMeasurementsStatus -> {
+ watchSync.receive(command)
+ AppResult.success(Unit)
+ }
+
+ is WearCommand.CheckWatchMeasurements,
+ is WearCommand.CopyWatchMeasurements,
+ is WearCommand.CancelWatchSync,
WearCommand.RequestAvailableSensors,
- WearCommand.SyncMeasurements,
is WearCommand.StartRecording,
-> AppResult.failure(AppError(AppErrorCode.VALIDATION, "Handle unsupported phone watch command"))
}
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/MeasurementsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt
deleted file mode 100644
index 7f04ecf..0000000
--- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.tomasrepcik.sensorbox.presentation.main
-
-import androidx.compose.foundation.clickable
-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.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.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import com.tomasrepcik.sensorbox.R
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary
-
-internal fun LazyListScope.measurementArchiveItems(
- state: MeasurementBrowserState,
- onIntent: (MeasurementBrowserIntent) -> Unit,
-) {
- when {
- state.isLoading && state.measurements.isEmpty() -> item { MeasurementLoading() }
-
- state.errorCode != null -> item { ArchiveError() }
-
- state.measurements.isEmpty() -> item { EmptyArchive() }
-
- else -> state.measurements.forEach { measurement ->
- item {
- MeasurementRow(measurement) {
- onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(measurement.id))
- }
- }
- }
- }
- if (state.isLoading && state.measurements.isNotEmpty()) item { MeasurementLoading() }
-}
-
-@Composable
-private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) {
- Column(
- Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- measurement.name,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- measurement.recordedAtText?.let { recordedAt ->
- Text(recordedAt, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- Text(
- stringResource(R.string.measurement_file_count, measurement.fileCount),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- SensorBoxSettingsDivider()
-}
diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt
deleted file mode 100644
index 2240913..0000000
--- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.tomasrepcik.sensorbox.presentation.main
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListScope
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.MaterialTheme
-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
-
-@Composable
-@OptIn(ExperimentalFoundationApi::class)
-internal fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> 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)) },
- )
- Spacer(Modifier.height(18.dp))
- }
- }
- }
- phoneSourceItems(state, onIntent)
- if (state.isWatchConnected) watchSourceItems(state, onIntent)
- item { RecordingMessageText(state.message) }
- }
-}
-
-private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) {
- item {
- GpsRow(
- selected = state.includesGps,
- onToggle = { onIntent(RecordingIntent.ToggleGps) },
- onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) },
- )
- }
- item { SourceDivider() }
- items(state.sensors, key = SensorDescriptor::type) { sensor ->
- SensorSourceItem(
- sensor = sensor,
- selected = sensor.type in state.selectedSensorIds,
- onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) },
- onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) },
- )
- }
-}
-
-private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) {
- item { WearSectionHeader() }
- item {
- GpsRow(
- selected = state.watchIncludesGps,
- onToggle = { onIntent(RecordingIntent.ToggleWatchGps) },
- )
- }
- item { SourceDivider() }
- items(state.watchSensors, key = { "watch_${it.type}" }) { sensor ->
- SensorSourceItem(
- sensor = sensor,
- selected = sensor.type in state.selectedWatchSensorIds,
- onToggle = { onIntent(RecordingIntent.ToggleWatchSensor(sensor.type)) },
- onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type, RecordingDevice.WATCH)) },
- )
- }
-}
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 73%
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..22b6533 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,20 @@
-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.core.storage.MeasurementSyncLock
+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
@@ -31,6 +34,7 @@ class AndroidPhoneRecordingController @Inject constructor(
@ApplicationContext private val context: Context,
private val intentFactory: RecordingIntentFactory,
private val documentStorage: DocumentStorage,
+ private val syncLock: MeasurementSyncLock = MeasurementSyncLock(),
) : PhoneRecordingController {
override fun start(sessionId: String, request: RecordingSetup): AppResult = documentStorage
.hasConfiguredDirectory()
@@ -51,7 +55,11 @@ class AndroidPhoneRecordingController @Inject constructor(
) {
val folderName = intentFactory.newFolderName(request.customName)
val recordingRequest = request.toRecordingRequest(sessionId, folderName)
- ContextCompat.startForegroundService(context, intentFactory.create(recordingRequest))
+ syncLock.whenIdle {
+ 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..f7ef9e6
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModel.kt
@@ -0,0 +1,78 @@
+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 isSyncingWatch: 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/recording/active/RecordContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContent.kt
new file mode 100644
index 0000000..b1e2a85
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContent.kt
@@ -0,0 +1,106 @@
+package com.tomasrepcik.sensorbox.recording.active
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.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: (RecordIntent) -> Unit,
+ watchSync: @Composable () -> 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(RecordIntent.Navigate(MainRoute.MEASUREMENTS)) },
+ onOptions = { onIntent(RecordIntent.Navigate(MainRoute.SETTINGS)) },
+ watchSync = watchSync,
+ )
+ Spacer(Modifier.height(18.dp))
+ }
+ }
+ }
+ item {
+ TextButton(onClick = { onIntent(RecordIntent.ToggleAllSensors) }) {
+ Text(
+ stringResource(
+ if (state.areAllSensorsSelected) {
+ R.string.deselect_all_sensors
+ } else {
+ R.string.select_all_sensors
+ },
+ ),
+ )
+ }
+ }
+ phoneSourceItems(state, onIntent)
+ if (state.isWatchConnected) watchSourceItems(state, onIntent)
+ item { RecordingMessageText(state.message) }
+ }
+}
+
+private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (RecordIntent) -> Unit) {
+ item {
+ GpsRow(
+ selected = state.includesGps,
+ onToggle = { onIntent(RecordIntent.ToggleGps) },
+ onInfo = { onIntent(RecordIntent.OpenSensorDetails(null)) },
+ )
+ }
+ item { SourceDivider() }
+ items(state.sensors, key = SensorDescriptor::type) { sensor ->
+ SensorSourceItem(
+ sensor = sensor,
+ selected = sensor.type in state.selectedSensorIds,
+ onToggle = { onIntent(RecordIntent.ToggleSensor(sensor.type)) },
+ onInfo = { onIntent(RecordIntent.OpenSensorDetails(sensor.type)) },
+ )
+ }
+}
+
+private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (RecordIntent) -> Unit) {
+ item { WearSectionHeader() }
+ item {
+ GpsRow(
+ selected = state.watchIncludesGps,
+ onToggle = { onIntent(RecordIntent.ToggleWatchGps) },
+ )
+ }
+ item { SourceDivider() }
+ items(state.watchSensors, key = { "watch_${it.type}" }) { sensor ->
+ SensorSourceItem(
+ sensor = sensor,
+ selected = sensor.type in state.selectedWatchSensorIds,
+ 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..c89ebc4
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordContract.kt
@@ -0,0 +1,91 @@
+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 ToggleAllSensors : 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.ToggleAllSensors -> RecordNext(state.toggleAllSensors())
+
+ 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)
+ }
+
+ private fun RecordingState.toggleAllSensors(): RecordingState {
+ val selectAll = !areAllSensorsSelected
+ return copy(
+ selectedSensorIds = if (selectAll) sensors.map { it.type }.toSet() else emptySet(),
+ includesGps = selectAll,
+ watchIncludesGps = if (isWatchConnected) selectAll else watchIncludesGps,
+ selectedWatchSensorIds = if (isWatchConnected) {
+ if (selectAll) watchSensors.map { it.type }.toSet() else emptySet()
+ } else {
+ selectedWatchSensorIds
+ },
+ message = RecordingMessage.NONE,
+ errorCode = null,
+ )
+ }
+}
+
+internal val RecordingState.areAllSensorsSelected: Boolean
+ get() = includesGps &&
+ sensors.all { it.type in selectedSensorIds } &&
+ (!isWatchConnected || watchIncludesGps && watchSensors.all { it.type in selectedWatchSensorIds })
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..5595fac
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/active/RecordRoot.kt
@@ -0,0 +1,37 @@
+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.measurements.sync.WatchSyncRoot
+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) {
+ WatchSyncRoot(
+ isWatchConnected = state.isWatchConnected,
+ )
+ }
+ }
+}
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 52%
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..278dd1e 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,15 +1,22 @@
-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,
+ watchSync: @Composable () -> Unit = {},
+) {
Column(modifier.fillMaxSize()) {
- Box(Modifier.weight(1f)) { RecordContent(state, onIntent) }
+ Box(Modifier.weight(1f)) { RecordContent(state, onIntent, watchSync) }
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 79%
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..34d5be8 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,9 @@
-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
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingSettings
data class RecordingSetup(
val sensorIds: Set,
@@ -60,4 +61,11 @@ fun StartedPhoneRecording.toWatchRecordingRequest(request: RecordingSetup) = Wea
sensorIds = request.watchSensorIds.sorted(),
includesGps = request.watchIncludesGps,
durationMillis = durationMillis,
+ settings = WearRecordingSettings(
+ samplingPeriodIndex = request.samplingPeriodIndex,
+ stopOnLowBattery = request.stopOnLowBattery,
+ useWakeLock = request.useWakeLock,
+ gpsIntervalSeconds = request.gpsIntervalSeconds,
+ gpsMinDistanceMeters = request.gpsMinDistanceMeters,
+ ),
)
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 75%
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..e171db9 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,21 +100,21 @@ 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
-internal fun RecordingArchivePanel(path: String?, onChoose: () -> Unit) {
+internal fun RecordingArchivePanel(path: String?, enabled: Boolean = true, onChoose: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -122,40 +127,44 @@ internal fun RecordingArchivePanel(path: String?, onChoose: () -> Unit) {
)
}
Spacer(Modifier.width(14.dp))
- SensorBoxSecondaryButton(stringResource(if (path == null) R.string.choose else R.string.change), onChoose)
+ SensorBoxSecondaryButton(
+ stringResource(if (path == null) R.string.choose else R.string.change),
+ onChoose,
+ enabled = enabled,
+ )
}
SensorBoxSettingsDivider()
}
@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 +172,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 +184,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 66%
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..33aa9e0 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, enabled = !state.isSyncingWatch) {
+ 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 59%
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..b53e43c 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)
@@ -30,12 +32,23 @@ internal fun RecordingSetupTopBar(
contentAlignment = Alignment.Center,
) {
SensorBoxPrimaryButton(
- 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) },
+ label = when {
+ state.isSyncingWatch -> stringResource(R.string.wait_for_watch_sync)
+
+ state.startCountdownSeconds != null -> pluralStringResource(
+ R.plurals.starting_in_seconds,
+ state.startCountdownSeconds,
+ state.startCountdownSeconds,
+ )
+
+ state.isStarting -> stringResource(R.string.starting_recording)
+
+ else -> stringResource(R.string.start_recording)
+ },
+ onClick = { onIntent(RecordingSetupIntent.StartRecording) },
modifier = Modifier.fillMaxWidth(),
- enabled = state.recordingArchivePath != null && sourceCount > 0 && !state.isStarting,
+ enabled =
+ state.recordingArchivePath != null && sourceCount > 0 && !state.isStarting && !state.isSyncingWatch,
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..b1fdaca
--- /dev/null
+++ b/app/src/main/java/com/tomasrepcik/sensorbox/recording/setup/RecordingSetupViewModel.kt
@@ -0,0 +1,249 @@
+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.core.storage.MeasurementSyncLock
+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
+import kotlin.time.Duration.Companion.milliseconds
+
+@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,
+ private val syncLock: MeasurementSyncLock = MeasurementSyncLock(),
+) : 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()
+ viewModelScope.launch {
+ syncLock.busy.collect { busy ->
+ mutableState.value = state.value.copy(isSyncingWatch = busy)
+ }
+ }
+ }
+
+ fun accept(intent: RecordingSetupIntent) {
+ if ((intent == RecordingSetupIntent.StartRecording || intent == RecordingSetupIntent.ChooseRecordingArchive) &&
+ syncLock.busy.value
+ ) {
+ appFailures.show(AppError(AppErrorCode.CONFLICT, "Wait for watch sync to finish first"))
+ return
+ }
+ 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
+ if (syncLock.busy.value) {
+ appFailures.show(AppError(AppErrorCode.CONFLICT, "Wait for watch sync before changing the archive"))
+ 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 = if (syncLock.busy.value) {
+ AppResult.failure(AppError(AppErrorCode.CONFLICT, "Wait for watch sync to finish first"))
+ } else {
+ 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.milliseconds)
+ }
+ 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 93%
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..2e0170c 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(
@@ -61,10 +63,11 @@ internal fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: (() -> Unit
}
@Composable
-internal fun RecordHeader(onMeasurements: () -> Unit, onOptions: () -> Unit) {
+internal fun RecordHeader(onMeasurements: () -> Unit, onOptions: () -> Unit, watchSync: @Composable () -> Unit = {}) {
SensorBoxTopAppBar(
title = stringResource(R.string.sources),
actions = {
+ watchSync()
IconButton(onClick = onMeasurements) {
Icon(
painterResource(R.drawable.ic_baseline_folder),
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 60%
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..48f1a9e 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,19 +1,20 @@
-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.bootstrap.ApplicationScope
+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.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,8 +39,10 @@ class RecordingSourceAvailability @Inject constructor(
phoneSensors: AvailableSensorsUseCase,
private val observeWatchCapability: ObserveWearCapabilityUseCase,
private val sendWatchCommand: SendWearCommandUseCase,
+ @ApplicationScope private val applicationScope: CoroutineScope,
) : AvailableRecordingSourcesUseCase,
ReceiveWatchSensorsUseCase {
+ private val started = AtomicBoolean(false)
private val mutableSources = MutableStateFlow(
AvailableRecordingSources(phoneSensors = phoneSensors()),
)
@@ -47,14 +50,13 @@ class RecordingSourceAvailability @Inject constructor(
override val current: AvailableRecordingSources
get() = mutableSources.value
- override fun observe(): Flow = channelFlow {
- launch { mutableSources.collect { sources -> send(sources) } }
- observeWatchCapability(WEAR_APP_CAPABILITY)
- .catch { error ->
- AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error)
- emit(WearConnection.Disconnected)
- }
- .collect(::onWatchConnectionChanged)
+ override fun observe(): Flow = mutableSources.asStateFlow()
+
+ fun start() {
+ if (!started.compareAndSet(false, true)) return
+ applicationScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ observeWatchCapability(WEAR_APP_CAPABILITY).collect(::onWatchConnectionChanged)
+ }
}
override fun receive(sensors: List) {
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/drawable/ic_measurement_open.xml b/app/src/main/res/drawable/ic_measurement_open.xml
new file mode 100644
index 0000000..f0c7a49
--- /dev/null
+++ b/app/src/main/res/drawable/ic_measurement_open.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_measurement_phone.xml b/app/src/main/res/drawable/ic_measurement_phone.xml
new file mode 100644
index 0000000..66c869f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_measurement_phone.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sync_watch.xml b/app/src/main/res/drawable/ic_sync_watch.xml
new file mode 100644
index 0000000..db5637e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sync_watch.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_sync_complete.xml b/app/src/main/res/drawable/ic_watch_sync_complete.xml
new file mode 100644
index 0000000..82cff2c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_sync_complete.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_sync_error.xml b/app/src/main/res/drawable/ic_watch_sync_error.xml
new file mode 100644
index 0000000..f582db2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_sync_error.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_syncing.xml b/app/src/main/res/drawable/ic_watch_syncing.xml
new file mode 100644
index 0000000..5080edc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_syncing.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/values/measurement_devices.xml b/app/src/main/res/values/measurement_devices.xml
new file mode 100644
index 0000000..12f6c13
--- /dev/null
+++ b/app/src/main/res/values/measurement_devices.xml
@@ -0,0 +1,5 @@
+
+ Phone
+ Watch
+ Phone + Watch · Paired recording
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 97cc84f..651433d 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,29 @@
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.
+ Select all sensors
+ Deselect all sensors
+ Step detected
+ Significant motion detected
+ No events recorded
+ %1$d recorded events
+ %1$s: %2$d%% confidence
+ %1$d events shown
+ This preview shows events sampled across the recording. The saved file contains all events. Numbers refer to the displayed events.
+ Step detected · %1$d
+ %1$s started
+ %1$s ended
+ %1$s · Unrecognized transition %2$s
+ Still
+ On foot
+ Walking
+ Running
+ In vehicle
+ Cycling
+ Tilting
+ Unknown activity
diff --git a/app/src/main/res/values/watch_sync.xml b/app/src/main/res/values/watch_sync.xml
new file mode 100644
index 0000000..5312672
--- /dev/null
+++ b/app/src/main/res/values/watch_sync.xml
@@ -0,0 +1,21 @@
+
+ Watch up to date. Nothing to sync
+ Retry
+ Watch sync
+ Checking watch…
+ Syncing · %1$d files saved
+ Sync complete · %1$d files saved
+ Sync failed · tap to retry
+ %1$d measurements to fetch
+ Saves watch measurements in your recording folder. Each measurement is deleted from the watch only after the phone confirms it was saved.
+ Check watch again
+ Sync from watch
+ Watch syncing. Show progress
+ %1$d of %2$d files saved
+ Close
+
+ - Fetch %1$d watch measurement
+ - Fetch %1$d watch measurements
+
+ Wait for watch sync
+
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/wear/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/about/PolicyLinksTest.kt
similarity index 96%
rename from wear/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/wear/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..760686e
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/MeasurementTestFixtures.kt
@@ -0,0 +1,67 @@
+package com.tomasrepcik.sensorbox.measurements
+
+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.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 detailsLoadCount = 0
+ 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 {
+ detailsLoadCount += 1
+ return detailsResult
+ }
+
+ override suspend fun loadMeasurementFile(
+ measurementId: String,
+ fileId: String,
+ onProgress: (Float) -> Unit,
+ ): AppResult {
+ fileProgress.forEach(onProgress)
+ fileLoadGate?.await()
+ return fileResult
+ }
+}
+
+internal fun measurementFailureStore() = AppFailureStore { }
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..0cd6077
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/details/MeasurementDetailsViewModelTest.kt
@@ -0,0 +1,70 @@
+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(),
+ )
+ }
+
+ @Test
+ fun `Given loaded details When the same screen returns Then its state is preserved`() = runTest {
+ // Given
+ val repository = FakeMeasurementRepository()
+ val viewModel = MeasurementDetailsViewModel(repository, measurementFailureStore())
+ viewModel.accept(MeasurementDetailsIntent.Load(MeasurementTestFixtures.summary.id))
+ advanceUntilIdle()
+ val loadedState = viewModel.state.value
+
+ // When
+ viewModel.accept(MeasurementDetailsIntent.Load(MeasurementTestFixtures.summary.id))
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(1, repository.detailsLoadCount)
+ assertEquals(loadedState, viewModel.state.value)
+ }
+}
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/list/RecordingGroupsTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/list/RecordingGroupsTest.kt
new file mode 100644
index 0000000..dfe821c
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/list/RecordingGroupsTest.kt
@@ -0,0 +1,56 @@
+package com.tomasrepcik.sensorbox.measurements.list
+
+import com.tomasrepcik.sensorbox.measurements.storage.MeasurementSummary
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RecordingGroupsTest {
+ @Test
+ fun `Given a shared session When measurements are listed Then phone and watch appear together`() {
+ // Given
+ val phone = measurement("renamed-phone", "session", "phone")
+ val watch = measurement("imported-watch", "session", "watch")
+
+ // When
+ val recordings = MeasurementsState(listOf(watch, phone)).recordings
+
+ // Then
+ assertEquals(listOf(listOf(phone, watch)), recordings)
+ }
+
+ @Test
+ fun `Given identical names and times When sessions differ Then measurements stay separate`() {
+ // Given
+ val phone = measurement("phone", "first", "phone")
+ val watch = measurement("watch", "second", "watch")
+
+ // When
+ val recordings = MeasurementsState(listOf(phone, watch)).recordings
+
+ // Then
+ assertEquals(2, recordings.size)
+ }
+
+ @Test
+ fun `Given older measurements When session identity is absent Then no relationship is guessed`() {
+ // Given
+ val phone = measurement("walk", null, null)
+ val watch = measurement("WEAR_walk", null, null)
+
+ // When
+ val recordings = MeasurementsState(listOf(phone, watch)).recordings
+
+ // Then
+ assertEquals(2, recordings.size)
+ }
+
+ private fun measurement(id: String, sessionId: String?, device: String?) = MeasurementSummary(
+ id = id,
+ name = "walk",
+ recordedAtMillis = 100L,
+ recordedAtText = "Today",
+ fileCount = 1,
+ sessionId = sessionId,
+ device = device,
+ )
+}
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/preview/RecordedEventKindTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEventKindTest.kt
new file mode 100644
index 0000000..b19abb1
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/preview/RecordedEventKindTest.kt
@@ -0,0 +1,52 @@
+package com.tomasrepcik.sensorbox.measurements.preview
+
+import com.google.android.gms.location.DetectedActivity
+import com.tomasrepcik.sensorbox.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class RecordedEventKindTest {
+ @Test
+ fun `Given event columns When preview is selected Then each event uses its detailed list`() {
+ // Given
+ val columns = listOf(
+ listOf("step"),
+ listOf("event"),
+ listOf("enter_exit", "activity"),
+ )
+
+ // When
+ val kinds = columns.map(::recordedEventKind)
+
+ // Then
+ assertEquals(RecordedEventKind.entries.toSet(), kinds.toSet())
+ }
+
+ @Test
+ fun `Given continuous sensor columns When preview is selected Then the chart remains available`() {
+ // Given
+ val columns = listOf("x", "y", "z")
+
+ // When
+ val kind = recordedEventKind(columns)
+
+ // Then
+ assertNull(kind)
+ assertNull(recordedEventKind(listOf("walking", "still", "running")))
+ }
+
+ @Test
+ fun `Given recorded activity codes When named Then walking and unknown values are readable`() {
+ // Given
+ val walking = DetectedActivity.WALKING.toDouble()
+
+ // When
+ val walkingName = activityNameResource(walking)
+ val unknownName = activityNameResource(999.0)
+
+ // Then
+ assertEquals(R.string.preview_activity_walking, walkingName)
+ assertEquals(R.string.preview_activity_unknown, unknownName)
+ }
+}
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 62%
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..d7b65ab 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,11 +1,41 @@
-package com.tomasrepcik.sensorbox.data.measurements
+package com.tomasrepcik.sensorbox.measurements.storage
-import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class MeasurementMetadataParserTest {
+ @Test
+ fun `Given ranges in a different order When parsed Then details match the sensor file`() {
+ // Given
+ val metadata = """
+ {
+ "ranges": [
+ {"sensor": "Gyroscope", "type": 4, "range": 35},
+ {"sensor": "Motion", "type": 17, "range": 1},
+ {"sensor": "Accelerometer", "type": 1, "range": 78}
+ ],
+ "sensorFiles": [
+ {"fileName": "accelerometer.csv", "writtenSamples": 12},
+ {"fileName": "gyroscope.csv", "writtenSamples": 10},
+ {"fileName": "custom.csv", "writtenSamples": 3}
+ ]
+ }
+ """.trimIndent()
+
+ // When
+ val parsed = MeasurementMetadataParser.parse(metadata)
+
+ // Then
+ assertEquals("Accelerometer", parsed.bySensorFile.getValue("accelerometer.csv").first().value)
+ assertEquals("Gyroscope", parsed.bySensorFile.getValue("gyroscope.csv").first().value)
+ assertEquals("Motion", parsed.bySensorFile.getValue("significant_motion.csv").first().value)
+ assertEquals(
+ listOf(MeasurementMetadataEntry("writtenSamples", "3")),
+ parsed.bySensorFile.getValue("custom.csv"),
+ )
+ }
+
@Test
fun `Given several sensors When metadata is parsed Then sensor details leave the session summary`() {
// Given
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParserTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParserTest.kt
new file mode 100644
index 0000000..3181274
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/storage/SensorSeriesParserTest.kt
@@ -0,0 +1,57 @@
+package com.tomasrepcik.sensorbox.measurements.storage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SensorSeriesParserTest {
+ private val anchor = SensorTimeAnchor(unixMillis = 1_700_000_000_000L, elapsedRealtimeNanos = 10_000_000_000L)
+
+ @Test
+ fun `Given activity updates When parsed Then elapsed milliseconds become recording wall time`() {
+ // Given
+ val format = sensorSeriesFormat(listOf("t_elapsed", "still", "walking"))
+
+ // When
+ val sample = parseSensorSample("12000;20;80", format, anchor)
+
+ // Then
+ assertEquals(SensorSeriesSample(1_700_000_002_000L, listOf(20.0, 80.0)), sample)
+ }
+
+ @Test
+ fun `Given activity transitions When parsed Then nanoseconds become recording wall time`() {
+ // Given
+ val format = sensorSeriesFormat(listOf("t_nanos", "activity", "enter_exit"))
+
+ // When
+ val sample = parseSensorSample("12000000000;7;0", format, anchor)
+
+ // Then
+ assertEquals(SensorSeriesSample(1_700_000_002_000L, listOf(7.0, 0.0)), sample)
+ }
+
+ @Test
+ fun `Given both timestamp columns When parsed Then Unix time takes precedence`() {
+ // Given
+ val format = sensorSeriesFormat(listOf("t_unix", "t_sensor", "step", "accuracy"))
+
+ // When
+ val sample = parseSensorSample("1700000002000;12000000000;1;3", format, anchor)
+
+ // Then
+ assertEquals(listOf("step"), format.columns)
+ assertEquals(SensorSeriesSample(1_700_000_002_000L, listOf(1.0)), sample)
+ }
+
+ @Test
+ fun `Given motion trigger When parsed Then its timestamp and event are preserved`() {
+ // Given
+ val format = sensorSeriesFormat(listOf("t_sensor", "event"))
+
+ // When
+ val sample = parseSensorSample("12000000000;1", format, anchor)
+
+ // Then
+ assertEquals(SensorSeriesSample(1_700_000_002_000L, listOf(1.0)), sample)
+ }
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTransferFixture.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTransferFixture.kt
new file mode 100644
index 0000000..ceafd51
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/SyncTransferFixture.kt
@@ -0,0 +1,21 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.google.android.gms.wearable.ChannelClient
+import com.tomasrepcik.sensorbox.core.failure.AppResult
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient
+import java.io.InputStream
+
+internal class SyncTransferFixture : WearFileTransferClient {
+ val cancelled = mutableListOf()
+ override fun cancel(requestId: String) {
+ cancelled += requestId
+ }
+ override suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult =
+ error("Not used")
+ override suspend fun reject(channel: ChannelClient.Channel): AppResult = AppResult.success(Unit)
+ override suspend fun receive(
+ channel: ChannelClient.Channel,
+ consume: (InputStream) -> AppResult,
+ ): AppResult = error("Not used")
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestinationTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestinationTest.kt
new file mode 100644
index 0000000..ad21a58
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchFileDestinationTest.kt
@@ -0,0 +1,94 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.tomasrepcik.sensorbox.core.storage.measurementFingerprint
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WatchFileDestinationTest {
+ private val storage = WatchSyncStorageFixture()
+ private val sync = WatchSyncRepo(
+ com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock(),
+ ).apply { begin("request", copy = true) }
+ private val destination = WatchFileDestination(storage, sync)
+ private val file = WearFileMetadata("walk", "sensor.csv", "request")
+ private val bytes = "time,x,y,z\n1,2,3,4".toByteArray()
+ private val fingerprint = measurementFingerprint(listOf(file.fileName to bytes::inputStream))
+ private val commit = file.copy(fileName = WearFileMetadata.COMMIT_FILE)
+
+ @Test
+ fun `Given an active sync When a file arrives Then the archive remains unchanged`() {
+ // Given
+ storage.savedFiles["WEAR_other/extra.json"] = "{}".toByteArray()
+
+ // When
+ val result = destination.copy(file, bytes.inputStream())
+
+ // Then
+ assertTrue(result.isSuccess)
+ assertEquals(setOf("WEAR_other/extra.json"), storage.savedFiles.keys)
+ }
+
+ @Test
+ fun `Given a complete staged measurement When the fingerprint matches Then the whole measurement is published`() {
+ // Given
+ destination.copy(file, bytes.inputStream()).getOrThrow()
+
+ // When
+ val result = destination.copy(commit, fingerprint.byteInputStream())
+
+ // Then
+ assertTrue(result.isSuccess)
+ assertArrayEquals(bytes, storage.savedFiles["WEAR_walk/sensor.csv"])
+ }
+
+ @Test
+ fun `Given an incomplete measurement When commit arrives Then existing archive data survives`() {
+ // Given
+ storage.savedFiles["WEAR_walk/sensor.csv"] = bytes
+ destination.copy(file, "partial".byteInputStream()).getOrThrow()
+
+ // When
+ val result = destination.copy(commit, fingerprint.byteInputStream())
+
+ // Then
+ assertTrue(result.isFailure)
+ assertArrayEquals(bytes, storage.savedFiles["WEAR_walk/sensor.csv"])
+ }
+
+ @Test
+ fun `Given a cancelled transfer When its commit arrives during retry Then no old measurement is published`() {
+ // Given
+ destination.copy(file, bytes.inputStream()).getOrThrow()
+ sync.fail("Cancelled", "request")
+ sync.begin("retry", copy = true)
+
+ // When
+ val result = destination.copy(commit, fingerprint.byteInputStream())
+ destination.discard("request")
+
+ // Then
+ assertTrue(result.isFailure)
+ assertTrue(storage.savedFiles.isEmpty())
+ assertEquals(1, storage.discarded)
+ assertTrue(sync.accepts("retry"))
+ }
+
+ @Test
+ fun `Given an acknowledged measurement When retry sends it again Then the existing copy remains valid`() {
+ // Given
+ destination.copy(file, bytes.inputStream()).getOrThrow()
+ destination.copy(commit, fingerprint.byteInputStream()).getOrThrow()
+
+ // When
+ destination.copy(file, bytes.inputStream()).getOrThrow()
+ val result = destination.copy(commit, fingerprint.byteInputStream())
+
+ // Then
+ assertTrue(result.isSuccess)
+ assertEquals(1, storage.savedFiles.size)
+ assertArrayEquals(bytes, storage.savedFiles["WEAR_walk/sensor.csv"])
+ }
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncManifestTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncManifestTest.kt
new file mode 100644
index 0000000..c0d6d01
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncManifestTest.kt
@@ -0,0 +1,33 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFilePathCodec
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+
+class WatchSyncManifestTest {
+ @Test
+ fun `Given the watch transfer path When the phone registers its listener Then the paths match`() {
+ // Given
+ val manifest = DocumentBuilderFactory.newInstance().newDocumentBuilder()
+ .parse(File("src/main/AndroidManifest.xml"))
+ val filters = manifest.getElementsByTagName("intent-filter")
+
+ // When
+ val channelFilter = (0 until filters.length).map { filters.item(it) }
+ .first { filter ->
+ (0 until filter.childNodes.length).any { index ->
+ filter.childNodes.item(index).attributes?.getNamedItem("android:name")?.nodeValue ==
+ "com.google.android.gms.wearable.CHANNEL_EVENT"
+ }
+ }
+ val registeredPath = (0 until channelFilter.childNodes.length)
+ .mapNotNull { index ->
+ channelFilter.childNodes.item(index).attributes?.getNamedItem("android:pathPrefix")?.nodeValue
+ }.single()
+
+ // Then
+ assertEquals(WearFilePathCodec.PREFIX, registeredPath)
+ }
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepoTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepoTest.kt
new file mode 100644
index 0000000..98ade43
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncRepoTest.kt
@@ -0,0 +1,153 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand
+import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WatchSyncRepoTest {
+ @Test
+ fun `Given a transfer When the watch finishes before files are saved Then sync stays in progress`() {
+ // Given
+ val repo = copyingRepo()
+
+ // When
+ repo.receive(finished())
+
+ // Then
+ assertEquals(WatchSyncStatus.COPYING, repo.state.value.status)
+ }
+
+ @Test
+ fun `Given a finished sender When the phone saves the last file Then sync is complete`() {
+ // Given
+ val repo = copyingRepo()
+ repo.receive(finished())
+
+ // When
+ repo.fileSaved(file)
+
+ // Then
+ assertEquals(WatchSyncStatus.COMPLETE, repo.state.value.status)
+ assertEquals(listOf("WEAR_walk/accelerometer.csv"), repo.state.value.receivedFiles)
+ }
+
+ @Test
+ fun `Given a saved file When the sender finishes Then sync is complete`() {
+ // Given
+ val repo = copyingRepo()
+ repo.fileSaved(file)
+
+ // When
+ repo.receive(finished())
+
+ // Then
+ assertEquals(WatchSyncStatus.COMPLETE, repo.state.value.status)
+ }
+
+ @Test
+ fun `Given a failed save When the sender finishes Then sync remains failed`() {
+ // Given
+ val repo = copyingRepo()
+ repo.fail("Cannot write to archive")
+
+ // When
+ repo.receive(finished())
+ repo.fileSaved(file)
+
+ // Then
+ assertEquals(WatchSyncStatus.FAILED, repo.state.value.status)
+ }
+
+ @Test
+ fun `Given a new request When an old response arrives Then it is ignored`() {
+ // Given
+ val repo = copyingRepo()
+
+ // When
+ repo.receive(finished().copy(requestId = "old"))
+
+ // Then
+ assertEquals(null, repo.state.value.fileCount)
+ assertEquals(false, repo.state.value.senderFinished)
+ }
+
+ @Test
+ fun `Given a watch without measurements When syncing finishes Then sync is complete`() {
+ // Given
+ val repo = copyingRepo()
+
+ // When
+ repo.receive(finished().copy(fileCount = 0, measurementCount = 0))
+
+ // Then
+ assertEquals(WatchSyncStatus.COMPLETE, repo.state.value.status)
+ }
+
+ @Test
+ fun `Given repeated notifications When a file is saved twice Then it is listed once`() {
+ // Given
+ val repo = copyingRepo()
+ repo.fileSaved(file)
+
+ // When
+ repo.fileSaved(file)
+
+ // Then
+ assertEquals(1, repo.state.value.receivedFiles.size)
+ }
+
+ @Test
+ fun `Given multiple files per measurement When availability arrives Then the badge counts measurements`() {
+ // Given
+ val repo = WatchSyncRepo(com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock())
+ repo.begin("check", copy = false)
+
+ // When
+ repo.receive(WearCommand.WatchMeasurementsStatus("check", 15, 2))
+
+ // Then
+ assertEquals(2, repo.state.value.measurementsToFetch)
+ }
+
+ @Test
+ fun `Given a completed copy When availability is refreshed Then no stale badge is displayed`() {
+ // Given
+ val repo = copyingRepo()
+ repo.fileSaved(file)
+ repo.receive(finished())
+ assertEquals(0, repo.state.value.measurementsToFetch)
+
+ // When
+ repo.begin("check", copy = false)
+
+ // Then
+ assertEquals(0, repo.state.value.measurementsToFetch)
+ }
+
+ @Test
+ fun `Given a retry When old files and failures arrive Then the retry remains untouched`() {
+ // Given
+ val repo = copyingRepo()
+ repo.fail("Timed out", "sync")
+ repo.begin("retry", copy = true)
+
+ // When
+ repo.fileSaved(file)
+ repo.fail("Old file failed", "sync")
+ repo.receive(finished())
+
+ // Then
+ assertEquals(WatchSyncStatus.COPYING, repo.state.value.status)
+ assertEquals(emptyList(), repo.state.value.receivedFiles)
+ assertEquals(true, repo.syncLock.busy.value)
+ }
+
+ private fun copyingRepo() = WatchSyncRepo(
+ com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock(),
+ ).apply { begin("sync", copy = true) }
+
+ private fun finished() = WearCommand.WatchMeasurementsStatus("sync", 1, 1, finished = true)
+
+ private val file = WearFileMetadata("walk", "accelerometer.csv", "sync")
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncStorageFixture.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncStorageFixture.kt
new file mode 100644
index 0000000..548e361
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncStorageFixture.kt
@@ -0,0 +1,55 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+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.storage.DocumentStorage
+import com.tomasrepcik.sensorbox.core.storage.MeasurementImport
+import com.tomasrepcik.sensorbox.core.storage.measurementFingerprint
+import java.io.InputStream
+import java.io.OutputStream
+
+internal class WatchSyncStorageFixture(var configured: Boolean = true) : DocumentStorage {
+ val savedFiles = mutableMapOf()
+ var discarded = 0
+
+ override fun beginMeasurementImport(measurementName: String): AppResult =
+ AppResult.success(object : MeasurementImport {
+ private val staged = mutableMapOf()
+ override fun copy(fileName: String, mimeType: String, input: InputStream): AppResult =
+ appResult(AppErrorCode.STORAGE, "Stage fixture file") { staged[fileName] = input.readBytes() }
+
+ override fun commit(expectedFingerprint: String): AppResult =
+ appResult(AppErrorCode.STORAGE, "Commit fixture measurement") {
+ check(
+ measurementFingerprint(
+ staged.map { (name, bytes) -> name to bytes::inputStream },
+ ) == expectedFingerprint,
+ )
+ savedFiles.keys.removeAll { it.startsWith("$measurementName/") }
+ staged.forEach { (name, bytes) -> savedFiles["$measurementName/$name"] = bytes }
+ }
+
+ override fun discard(): AppResult {
+ discarded += 1
+ staged.clear()
+ return AppResult.success(Unit)
+ }
+ })
+
+ override fun hasConfiguredDirectory(): AppResult = AppResult.success(configured)
+
+ override fun displayPath(): AppResult = AppResult.success("Documents/Measurements")
+
+ override fun persistRootAccess(uri: String, grantFlags: Int): AppResult = error("Not used")
+
+ override fun createMeasurementDirectory(measurementName: String): AppResult = error("Not used")
+
+ override fun deleteMeasurement(measurementName: String): AppResult = error("Not used")
+
+ override fun openMeasurementFile(
+ measurementName: String,
+ mimeType: String,
+ fileName: String,
+ ): AppResult = error("Not used")
+}
diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModelTest.kt
new file mode 100644
index 0000000..713fc98
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/measurements/sync/WatchSyncViewModelTest.kt
@@ -0,0 +1,198 @@
+package com.tomasrepcik.sensorbox.measurements.sync
+
+import androidx.lifecycle.ViewModelStore
+import com.tomasrepcik.sensorbox.core.failure.AppError
+import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
+import com.tomasrepcik.sensorbox.testing.MainDispatcherRule
+import com.tomasrepcik.sensorbox.wearoslib.connection.FakeWearConnectionRepository
+import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase
+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.sync.WearFileMetadata
+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.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WatchSyncViewModelTest {
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+ private val connection = FakeWearConnectionRepository()
+ private val storage = WatchSyncStorageFixture()
+ private val repo = WatchSyncRepo(com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock())
+ private val transfers = SyncTransferFixture()
+
+ @Test
+ fun `Given a connected watch When checking Then available measurement counts are shown`() = runTest {
+ // Given
+ val viewModel = viewModel()
+
+ // When
+ viewModel.accept(WatchSyncIntent.CHECK)
+ runCurrent()
+ val command = sentCommand() as WearCommand.CheckWatchMeasurements
+ repo.receive(WearCommand.WatchMeasurementsStatus(command.requestId, 3, 2))
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(WatchSyncStatus.AVAILABLE, viewModel.state.value.status)
+ assertEquals(1, connection.sentMessages.size)
+ assertTrue(sentCommand() is WearCommand.CheckWatchMeasurements)
+ assertEquals(3, viewModel.state.value.fileCount)
+ assertEquals(2, viewModel.state.value.measurementCount)
+ }
+
+ @Test
+ fun `Given no archive When syncing Then no transfer is requested`() = runTest {
+ // Given
+ storage.configured = false
+ val viewModel = viewModel()
+
+ // When
+ viewModel.accept(WatchSyncIntent.COPY)
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(WatchSyncStatus.FAILED, viewModel.state.value.status)
+ assertTrue(connection.sentMessages.isEmpty())
+ }
+
+ @Test
+ fun `Given an unreachable watch When checking Then a connection error is shown`() = runTest {
+ // Given
+ connection.failSending(AppError(AppErrorCode.CONNECTIVITY, "Find watch"))
+ val viewModel = viewModel()
+
+ // When
+ viewModel.accept(WatchSyncIntent.CHECK)
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(WatchSyncStatus.FAILED, viewModel.state.value.status)
+ }
+
+ @Test
+ fun `Given an unresponsive watch When the request expires Then retry is possible`() = runTest {
+ // Given
+ val viewModel = viewModel()
+
+ // When
+ viewModel.accept(WatchSyncIntent.CHECK)
+ advanceUntilIdle()
+ viewModel.accept(WatchSyncIntent.CHECK)
+ runCurrent()
+
+ // Then
+ assertEquals(2, connection.sentMessages.size)
+ assertEquals(WatchSyncStatus.CHECKING, viewModel.state.value.status)
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `Given an active sync When sync is tapped again Then only one request is sent`() = runTest {
+ // Given
+ val viewModel = viewModel()
+ viewModel.accept(WatchSyncIntent.COPY)
+ runCurrent()
+
+ // When
+ viewModel.accept(WatchSyncIntent.COPY)
+ val command = sentCommand() as WearCommand.CopyWatchMeasurements
+ repo.fileSaved(WearFileMetadata("walk", "sensor.csv", command.requestId))
+ repo.receive(WearCommand.WatchMeasurementsStatus(command.requestId, 1, 1, finished = true))
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(1, connection.sentMessages.size)
+ assertEquals(WatchSyncStatus.COMPLETE, viewModel.state.value.status)
+ }
+
+ @Test
+ fun `Given a pending check When the screen is removed Then sync can be retried`() = runTest {
+ // Given
+ val viewModel = viewModel()
+ val store = ViewModelStore()
+ store.put("sync", viewModel)
+ viewModel.accept(WatchSyncIntent.CHECK)
+ runCurrent()
+
+ // When
+ store.clear()
+
+ // Then
+ assertEquals(WatchSyncStatus.FAILED, viewModel.state.value.status)
+ }
+
+ @Test
+ fun `Given failed copy When retry is tapped Then copying is requested again`() = runTest {
+ // Given
+ val viewModel = viewModel()
+ viewModel.accept(WatchSyncIntent.COPY)
+ runCurrent()
+ repo.fail("Transfer interrupted")
+ runCurrent()
+
+ // When
+ viewModel.accept(WatchSyncIntent.RETRY)
+ runCurrent()
+
+ // Then
+ assertTrue(sentCommand() is WearCommand.CopyWatchMeasurements)
+ assertEquals(3, connection.sentMessages.size)
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `Given failed check When retry is tapped Then no files are requested`() = runTest {
+ // Given
+ val viewModel = viewModel()
+ viewModel.accept(WatchSyncIntent.CHECK)
+ advanceUntilIdle()
+
+ // When
+ viewModel.accept(WatchSyncIntent.RETRY)
+ runCurrent()
+
+ // Then
+ assertTrue(sentCommand() is WearCommand.CheckWatchMeasurements)
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `Given an unresponsive copy When its deadline expires Then channels close and watch cancellation is sent`() =
+ runTest {
+ // Given
+ val viewModel = viewModel()
+ viewModel.accept(WatchSyncIntent.COPY)
+ runCurrent()
+ val request = sentCommand() as WearCommand.CopyWatchMeasurements
+
+ // When
+ advanceUntilIdle()
+
+ // Then
+ assertTrue(request.requestId in transfers.cancelled)
+ assertEquals(WearCommand.CancelWatchSync(request.requestId), sentCommand())
+ assertEquals(WatchSyncStatus.FAILED, viewModel.state.value.status)
+ assertEquals(false, repo.syncLock.busy.value)
+ }
+
+ private fun sentCommand(): WearCommand = checkNotNull(
+ WearCommandCodec.decode(connection.sentMessages.last().payload).getOrNull(),
+ )
+
+ private fun viewModel() = WatchSyncViewModel(
+ repo,
+ SendWearCommandUseCase(SendWearMessageUseCase(connection)),
+ storage,
+ mainDispatcherRule.dispatcher,
+ transfers,
+ WatchFileDestination(storage, repo),
+ )
+}
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 57%
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..4aae23e 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,45 @@
-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.DiagnosticsStore
import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode
+import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock
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.RecordIntent
+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
@@ -41,12 +56,36 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
class FeatureViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
+ @Test
+ fun `Given available sensors When selecting all Then the ViewModel publishes their selection`() = runTest {
+ // Given
+ val sources = FakeAvailableRecordingSources(
+ AvailableRecordingSources(
+ phoneSensors = listOf(SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture")),
+ watchSensors = listOf(SensorDescriptor(type = 4, name = "Gyroscope", vendor = "Fixture")),
+ isWatchConnected = true,
+ ),
+ )
+ val viewModel = RecordViewModel(sources, failureStore())
+ advanceUntilIdle()
+
+ // When
+ viewModel.accept(RecordIntent.ToggleAllSensors)
+
+ // Then
+ assertEquals(setOf(1), viewModel.state.value.selectedSensorIds)
+ assertEquals(setOf(4), viewModel.state.value.selectedWatchSensorIds)
+ assertTrue(viewModel.state.value.includesGps)
+ assertTrue(viewModel.state.value.watchIncludesGps)
+ }
+
@Test
fun `Given onboarding recording archive When completed Then state and navigation effect are owned by onboarding`() =
runTest {
@@ -162,10 +201,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 +217,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 +264,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 +280,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,27 +305,56 @@ 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
assertEquals(2, viewModel.state.value.startCountdownSeconds)
assertEquals(0, recording.startCalls)
- advanceTimeBy(1_000L)
+ advanceTimeBy(1_000L.milliseconds)
runCurrent()
assertEquals(1, viewModel.state.value.startCountdownSeconds)
assertEquals(0, recording.startCalls)
- advanceTimeBy(1_000L)
+ advanceTimeBy(1_000L.milliseconds)
runCurrent()
assertEquals(null, viewModel.state.value.startCountdownSeconds)
assertEquals(1, recording.startCalls)
@@ -275,29 +362,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 +407,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,32 +428,86 @@ 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(
+ @Test
+ fun `Given active sync When recording starts Then no recording is requested`() = runTest {
+ // Given
+ val lock = MeasurementSyncLock()
+ val recording = FakeRecordingWorkflow()
+ val viewModel = recordingSetupViewModel(recording, syncLock = lock)
+ viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1))))
+ lock.begin("request")
+ runCurrent()
+
+ // When
+ viewModel.accept(RecordingSetupIntent.StartRecording)
+ advanceUntilIdle()
+
+ // Then
+ assertTrue(viewModel.state.value.isSyncingWatch)
+ assertEquals(0, recording.startCalls)
+ }
+
+ @Test
+ fun `Given a countdown When sync starts before it ends Then recording is not started`() = runTest {
+ // Given
+ val lock = MeasurementSyncLock()
+ val recording = FakeRecordingWorkflow()
+ val viewModel = recordingSetupViewModel(recording, syncLock = lock)
+ viewModel.accept(RecordingSetupIntent.LoadDraft(RecordingDraft(selectedSensorIds = setOf(1))))
+ viewModel.accept(RecordingSetupIntent.SetStartDelay(2))
+ viewModel.accept(RecordingSetupIntent.StartRecording)
+ runCurrent()
+
+ // When
+ lock.begin("request")
+ advanceUntilIdle()
+
+ // Then
+ assertEquals(0, recording.startCalls)
+ assertFalse(viewModel.state.value.isStarting)
+ }
+
+ @Test
+ fun `Given an open folder picker When sync starts before selection returns Then the archive is unchanged`() =
+ runTest {
+ // Given
+ val lock = MeasurementSyncLock()
+ val archive = FakeRecordingArchiveRepository(isSelected = false)
+ val viewModel = recordingSetupViewModel(FakeRecordingWorkflow(), archive = archive, syncLock = lock)
+ viewModel.accept(RecordingSetupIntent.ChooseRecordingArchive)
+ lock.begin("request")
+
+ // When
+ viewModel.handleRecordingArchiveResult(RecordingArchiveSelection.Selected("content://new", 3))
+
+ // Then
+ assertFalse(archive.isSelected().getOrThrow())
+ }
+
+ private fun recordingSetupViewModel(
workflow: RecordingControlUseCase,
- availableSources: AvailableRecordingSourcesUseCase = FakeAvailableRecordingSources(),
sessionStore: RecordingSessionStore = RecordingSessionStore(),
archive: RecordingArchiveRepository = FakeRecordingArchiveRepository(isSelected = true),
appFailures: AppFailureStore = failureStore(),
- ): RecordingViewModel = RecordingViewModel(
+ syncLock: MeasurementSyncLock = MeasurementSyncLock(),
+ ): 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(),
+ syncLock = syncLock,
)
- private fun failureStore() = AppFailureStore(DiagnosticLogger { })
+ private fun failureStore() = AppFailureStore { }
}
private class FakeAvailableRecordingSources(
@@ -381,16 +527,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 path(): AppResult = selectedResult.fold(
- onSuccess = { AppResult.success(if (it) "fixture" else null) },
+ override fun isSelected(): AppResult = selectedResult ?: AppResult.success(selected)
+
+ 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..ca01739
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/active/RecordReducerTest.kt
@@ -0,0 +1,129 @@
+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.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RecordReducerTest {
+ @Test
+ fun `Given phone and connected watch When selecting all Then every sensor is selected`() {
+ // Given
+ val state = RecordingStateFixtures.state().copy(
+ isWatchConnected = true,
+ watchSensors = RecordingStateFixtures.state().sensors.map { it.copy(type = 4) },
+ )
+
+ // When
+ val selected = RecordReducer.reduce(state, RecordIntent.ToggleAllSensors).state
+
+ // Then
+ assertEquals(setOf(1), selected.selectedSensorIds)
+ assertEquals(setOf(4), selected.selectedWatchSensorIds)
+ assertTrue(selected.includesGps)
+ assertTrue(selected.watchIncludesGps)
+ assertTrue(selected.areAllSensorsSelected)
+ }
+
+ @Test
+ fun `Given every phone and connected watch sensor selected When toggling all Then every sensor is deselected`() {
+ // Given
+ val selected = RecordingStateFixtures.state(includesGps = true).copy(
+ selectedSensorIds = setOf(1),
+ isWatchConnected = true,
+ watchSensors = RecordingStateFixtures.state().sensors.map { it.copy(type = 4) },
+ selectedWatchSensorIds = setOf(4),
+ watchIncludesGps = true,
+ )
+
+ // When
+ val deselected = RecordReducer.reduce(selected, RecordIntent.ToggleAllSensors).state
+
+ // Then
+ assertTrue(deselected.selectedSensorIds.isEmpty())
+ assertTrue(deselected.selectedWatchSensorIds.isEmpty())
+ assertFalse(deselected.includesGps)
+ assertFalse(deselected.watchIncludesGps)
+ assertFalse(deselected.areAllSensorsSelected)
+ }
+
+ @Test
+ fun `Given disconnected watch When selecting all Then watch selection is preserved`() {
+ // Given
+ val state = RecordingStateFixtures.state().copy(selectedWatchSensorIds = setOf(4))
+
+ // When
+ val selected = RecordReducer.reduce(state, RecordIntent.ToggleAllSensors).state
+
+ // Then
+ assertEquals(setOf(1), selected.selectedSensorIds)
+ assertEquals(setOf(4), selected.selectedWatchSensorIds)
+ }
+
+ @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 82%
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..acedb23 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,11 +1,11 @@
-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 com.tomasrepcik.sensorbox.core.storage.MeasurementImport
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
-import java.io.InputStream
import java.io.OutputStream
class DocumentRecordingArchiveRepositoryTest {
@@ -23,6 +23,8 @@ class DocumentRecordingArchiveRepositoryTest {
}
private class FakeDocumentStorage : DocumentStorage {
+ override fun beginMeasurementImport(measurementName: String): AppResult = error("Not used")
+
var persistedUri: String? = null
var persistedFlags: Int? = null
@@ -42,16 +44,8 @@ class DocumentRecordingArchiveRepositoryTest {
measurementName: String,
mimeType: String,
fileName: String,
- replaceExisting: Boolean,
): AppResult = error("not used")
override fun deleteMeasurement(measurementName: String): AppResult = AppResult.success(Unit)
-
- override fun copyToMeasurement(
- input: InputStream,
- measurementName: String,
- fileName: String,
- mimeType: String,
- ): AppResult = AppResult.success(Unit)
}
}
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/recording/setup/WatchRecordingSetupTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/WatchRecordingSetupTest.kt
new file mode 100644
index 0000000..bf4e58c
--- /dev/null
+++ b/app/src/test/java/com/tomasrepcik/sensorbox/recording/setup/WatchRecordingSetupTest.kt
@@ -0,0 +1,37 @@
+package com.tomasrepcik.sensorbox.recording.setup
+
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec
+import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WatchRecordingSetupTest {
+ @Test
+ fun `Given phone setup When sent to the watch Then its sources and recording settings survive transport`() {
+ // Given
+ val setup = RecordingSetup(
+ sensorIds = setOf(2),
+ includesGps = false,
+ samplingPeriodIndex = 2,
+ stopOnLowBattery = false,
+ useWakeLock = true,
+ gpsIntervalSeconds = 7,
+ gpsMinDistanceMeters = 3,
+ watchSensorIds = setOf(4),
+ watchIncludesGps = true,
+ )
+ val started = StartedPhoneRecording("phone", "walk", 60_000L)
+ val command = WearCommand.StartRecording("phone", started.toWatchRecordingRequest(setup))
+
+ // When
+ val payload = checkNotNull(WearCommandCodec.encode(command).getOrNull())
+ val received = WearCommandCodec.decode(payload).getOrNull() as WearCommand.StartRecording
+
+ // Then
+ assertEquals(listOf(4), received.request.sensorIds)
+ assertEquals(true, received.request.includesGps)
+ assertEquals(60_000L, received.request.durationMillis)
+ assertEquals(WearRecordingSettings(2, false, true, 7, 3), received.request.settings)
+ }
+}
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 54%
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..22bae8d 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,17 +1,17 @@
-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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -23,15 +23,13 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecordingSourceAvailabilityTest {
@Test
- fun `Given connected watch When sources are observed Then discovery is requested`() = runTest {
+ fun `Given connected watch When availability starts Then discovery is requested`() = runTest {
// Given
val repository = connectedRepository()
- val availability = availability(repository)
- backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- availability.observe().collect()
- }
+ val availability = availability(repository, applicationScope())
// When
+ availability.start()
advanceUntilIdle()
// Then
@@ -40,10 +38,27 @@ class RecordingSourceAvailabilityTest {
assertFalse(availability.current.isWatchConnected)
}
+ @Test
+ fun `Given disconnected watch When it connects later Then discovery is requested`() = runTest {
+ // Given
+ val repository = FakeWearConnectionRepository()
+ val availability = availability(repository, applicationScope())
+ availability.start()
+ advanceUntilIdle()
+
+ // When
+ repository.emit(WearConnection.Connected(WearNode("watch", "Watch", isNearby = true)))
+ advanceUntilIdle()
+
+ // Then
+ val command = WearCommandCodec.decode(repository.sentMessages.single().payload).getOrThrow()
+ assertEquals(WearCommand.RequestAvailableSensors, command)
+ }
+
@Test
fun `Given an empty watch response When it is received Then watch is available`() = runTest {
// Given
- val availability = availability(connectedRepository())
+ val availability = availability(connectedRepository(), applicationScope())
// When
availability.receive(emptyList())
@@ -56,7 +71,7 @@ class RecordingSourceAvailabilityTest {
@Test
fun `Given watch sensors When received Then details are sorted and retained`() = runTest {
// Given
- val availability = availability(connectedRepository())
+ val availability = availability(connectedRepository(), applicationScope())
// When
availability.receive(listOf(sensor(2, "Z sensor"), sensor(1, "A sensor"), sensor(1, "Duplicate")))
@@ -70,11 +85,10 @@ class RecordingSourceAvailabilityTest {
fun `Given available watch sources When watch disconnects Then watch sources clear`() = runTest {
// Given
val repository = connectedRepository()
- val availability = availability(repository)
+ val availability = availability(repository, applicationScope())
availability.receive(listOf(sensor(1, "Sensor")))
- backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- availability.observe().collect()
- }
+ availability.start()
+ advanceUntilIdle()
// When
repository.emit(WearConnection.Disconnected)
@@ -85,10 +99,18 @@ class RecordingSourceAvailabilityTest {
assertTrue(availability.current.watchSensors.isEmpty())
}
- private fun availability(repository: FakeWearConnectionRepository) = RecordingSourceAvailability(
- phoneSensors = AvailableSensorsUseCase { emptyList() },
+ private fun availability(
+ repository: FakeWearConnectionRepository,
+ scope: CoroutineScope,
+ ): RecordingSourceAvailability = RecordingSourceAvailability(
+ phoneSensors = { emptyList() },
observeWatchCapability = ObserveWearCapabilityUseCase(repository),
sendWatchCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)),
+ applicationScope = scope,
+ )
+
+ private fun TestScope.applicationScope() = CoroutineScope(
+ backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler),
)
private fun connectedRepository() = FakeWearConnectionRepository(
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/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprint.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprint.kt
new file mode 100644
index 0000000..81421ff
--- /dev/null
+++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprint.kt
@@ -0,0 +1,23 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import java.io.InputStream
+import java.security.MessageDigest
+
+fun measurementFingerprint(files: List InputStream>>): String {
+ val measurement = MessageDigest.getInstance("SHA-256")
+ files.sortedBy { it.first }.forEach { (name, open) ->
+ val contents = MessageDigest.getInstance("SHA-256")
+ open().use { input ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var count = input.read(buffer)
+ while (count >= 0) {
+ if (count > 0) contents.update(buffer, 0, count)
+ count = input.read(buffer)
+ }
+ }
+ measurement.update(name.encodeToByteArray())
+ measurement.update(0.toByte())
+ measurement.update(contents.digest())
+ }
+ return measurement.digest().joinToString("") { byte -> "%02x".format(byte) }
+}
diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLock.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLock.kt
new file mode 100644
index 0000000..f28862b
--- /dev/null
+++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLock.kt
@@ -0,0 +1,35 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Serializes starting sync with recording starts and archive selection. */
+class MeasurementSyncLock {
+ private val requests = mutableSetOf()
+ private val mutableBusy = MutableStateFlow(false)
+ val busy = mutableBusy.asStateFlow()
+
+ @Synchronized
+ fun begin(requestId: String) {
+ requests.add(requestId)
+ mutableBusy.value = true
+ }
+
+ @Synchronized
+ fun finish(requestId: String) {
+ requests.remove(requestId)
+ mutableBusy.value = requests.isNotEmpty()
+ }
+
+ @Synchronized
+ fun whenIdle(operation: () -> T): T {
+ check(requests.isEmpty()) { "Wait for watch sync to finish first" }
+ return operation()
+ }
+
+ @Synchronized
+ fun whileActive(requestId: String, operation: () -> T): T {
+ check(requestId in requests) { "Watch sync is no longer active" }
+ return operation()
+ }
+}
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-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprintTest.kt b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprintTest.kt
new file mode 100644
index 0000000..258afce
--- /dev/null
+++ b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementFingerprintTest.kt
@@ -0,0 +1,49 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class MeasurementFingerprintTest {
+ @Test
+ fun `Given the same files in different orders When compared Then their fingerprints match`() {
+ // Given
+ val files = listOf(file("extra.json", "metadata"), file("sensor.csv", "1,2,3"))
+
+ // When
+ val original = measurementFingerprint(files)
+ val copied = measurementFingerprint(files.reversed())
+
+ // Then
+ assertEquals(original, copied)
+ }
+
+ @Test
+ fun `Given changed data of the same length When compared Then the measurement needs another copy`() {
+ // Given
+ val original = listOf(file("sensor.csv", "1,2,3"))
+ val changed = listOf(file("sensor.csv", "1,2,4"))
+
+ // When
+ val oldFingerprint = measurementFingerprint(original)
+ val newFingerprint = measurementFingerprint(changed)
+
+ // Then
+ assertNotEquals(oldFingerprint, newFingerprint)
+ }
+
+ @Test
+ fun `Given a missing file When compared Then the measurement needs a copy`() {
+ // Given
+ val original = listOf(file("sensor.csv", "1,2,3"), file("extra.json", "metadata"))
+
+ // When
+ val fingerprint = measurementFingerprint(original)
+ val incomplete = measurementFingerprint(original.take(1))
+
+ // Then
+ assertNotEquals(fingerprint, incomplete)
+ }
+
+ private fun file(name: String, contents: String) = name to { contents.byteInputStream() }
+}
diff --git a/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLockTest.kt b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLockTest.kt
new file mode 100644
index 0000000..f24be35
--- /dev/null
+++ b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/storage/MeasurementSyncLockTest.kt
@@ -0,0 +1,54 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MeasurementSyncLockTest {
+ @Test
+ fun `Given sync is active When recording or archive changes are attempted Then neither operation runs`() {
+ // Given
+ val lock = MeasurementSyncLock()
+ lock.begin("request")
+ var calls = 0
+
+ // When
+ val result = runCatching { lock.whenIdle { calls += 1 } }
+
+ // Then
+ assertTrue(result.isFailure)
+ assertEquals(0, calls)
+ }
+
+ @Test
+ fun `Given a retry is active When the old request finishes Then recording remains blocked`() {
+ // Given
+ val lock = MeasurementSyncLock()
+ lock.begin("old")
+ lock.begin("retry")
+
+ // When
+ lock.finish("old")
+
+ // Then
+ assertTrue(lock.busy.value)
+ assertTrue(runCatching { lock.whenIdle { } }.isFailure)
+ lock.finish("retry")
+ assertFalse(lock.busy.value)
+ }
+
+ @Test
+ fun `Given a cancelled request When commit is attempted Then its archive operation cannot run`() {
+ // Given
+ val lock = MeasurementSyncLock()
+ lock.begin("request")
+ lock.finish("request")
+
+ // When
+ val result = runCatching { lock.whileActive("request") { error("Must not run") } }
+
+ // Then
+ assertEquals("Watch sync is no longer active", result.exceptionOrNull()?.message)
+ }
+}
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/MeasurementImport.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/MeasurementImport.kt
new file mode 100644
index 0000000..05e0ec0
--- /dev/null
+++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/MeasurementImport.kt
@@ -0,0 +1,15 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import com.tomasrepcik.sensorbox.core.failure.AppResult
+import java.io.InputStream
+
+/** A hidden measurement pinned to one archive until committed or discarded. */
+interface MeasurementImport {
+ fun copy(fileName: String, mimeType: String, input: InputStream): AppResult
+ fun commit(expectedFingerprint: String): AppResult
+ fun discard(): AppResult
+}
+
+const val PENDING_MEASUREMENT_PREFIX = ".sensorbox-sync-"
+
+const val BACKUP_MEASUREMENT_PREFIX = ".sensorbox-backup-"
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..89f25c8 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,15 +4,16 @@ 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 java.io.InputStream
+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.OutputStream
interface DocumentStorage {
+ fun beginMeasurementImport(measurementName: String): AppResult
+
fun persistRootAccess(uri: String, grantFlags: Int): AppResult
fun hasConfiguredDirectory(): AppResult
@@ -21,25 +22,48 @@ interface DocumentStorage {
fun createMeasurementDirectory(measurementName: String): AppResult
- fun openMeasurementFile(
- measurementName: String,
- mimeType: String,
- fileName: String,
- replaceExisting: Boolean = false,
- ): AppResult
+ fun openMeasurementFile(measurementName: String, mimeType: String, fileName: String): AppResult
fun deleteMeasurement(measurementName: String): AppResult
-
- fun copyToMeasurement(
- input: InputStream,
- measurementName: String,
- fileName: String,
- mimeType: String,
- ): AppResult
}
-class NativeDocumentStorage(private val context: Context) : DocumentStorage {
- override fun persistRootAccess(uri: String, grantFlags: Int): AppResult {
+class NativeDocumentStorage(
+ private val context: Context,
+ private val syncLock: MeasurementSyncLock = MeasurementSyncLock(),
+) : DocumentStorage {
+ private val preparedArchives = mutableSetOf()
+
+ @Synchronized
+ override fun beginMeasurementImport(measurementName: String): AppResult =
+ appResult(AppErrorCode.STORAGE, "Stage watch measurement") {
+ val root = checkNotNull(configuredDirectory()) { "Recording archive is unavailable" }
+ if (root.uri.toString() !in preparedArchives) {
+ recoverInterruptedImports(root)
+ preparedArchives.add(root.uri.toString())
+ }
+ NativeMeasurementImport(context, root, measurementName)
+ }
+
+ private fun recoverInterruptedImports(root: DocumentFile) {
+ root.listFiles().filter { it.name.orEmpty().startsWith(BACKUP_MEASUREMENT_PREFIX) }.forEach { backup ->
+ val name = checkNotNull(backup.name).removePrefix(BACKUP_MEASUREMENT_PREFIX)
+ if (root.findFile(name) == null) {
+ check(backup.renameTo(name)) { "Could not restore previous measurement" }
+ } else {
+ check(backup.delete()) { "Could not remove previous measurement backup" }
+ }
+ }
+ root.listFiles().filter { it.name.orEmpty().startsWith(PENDING_MEASUREMENT_PREFIX) }.forEach { pending ->
+ check(pending.delete()) { "Could not remove interrupted watch transfer" }
+ }
+ }
+
+ override fun persistRootAccess(uri: String, grantFlags: Int): AppResult =
+ appResult(AppErrorCode.CONFLICT, "Change recording archive") {
+ syncLock.whenIdle { persistSelectedRoot(uri, grantFlags) }
+ }.flatMap { it }
+
+ private fun persistSelectedRoot(uri: String, grantFlags: Int): AppResult {
val selectedUri = Uri.parse(uri)
val persistedFlags = grantFlags and READ_WRITE_FLAGS
if (persistedFlags == 0) return storageFailure("Storage permission was not granted")
@@ -103,12 +127,11 @@ class NativeDocumentStorage(private val context: Context) : DocumentStorage {
measurementName: String,
mimeType: String,
fileName: String,
- replaceExisting: Boolean,
): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement directory") {
measurementDirectory(measurementName)
}.flatMap { directory ->
if (directory == null) return@flatMap storageFailure("Measurement directory is unavailable")
- createOrReplaceFile(directory, mimeType, fileName, replaceExisting).flatMap { createdFile ->
+ findOrCreateFile(directory, mimeType, fileName).flatMap { createdFile ->
if (createdFile == null) return@flatMap storageFailure("Unable to create measurement file")
appResult(AppErrorCode.STORAGE, "Open measurement file") {
context.contentResolver.openOutputStream(createdFile.uri, "wt")
@@ -130,23 +153,6 @@ class NativeDocumentStorage(private val context: Context) : DocumentStorage {
}
}
- override fun copyToMeasurement(
- input: InputStream,
- measurementName: String,
- fileName: String,
- mimeType: String,
- ): AppResult = openMeasurementFile(
- measurementName = measurementName,
- mimeType = mimeType,
- fileName = fileName,
- replaceExisting = true,
- ).flatMap { output ->
- appResult(AppErrorCode.STORAGE, "Copy measurement file") {
- input.use { source -> output.use(source::copyTo) }
- Unit
- }
- }
-
private fun measurementDirectory(measurementName: String): DocumentFile? {
val appDirectory = configuredDirectory() ?: return null
return findDirectory(appDirectory, measurementName)
@@ -172,24 +178,15 @@ class NativeDocumentStorage(private val context: Context) : DocumentStorage {
}
}
-private fun createOrReplaceFile(
- directory: DocumentFile,
- mimeType: String,
- fileName: String,
- replaceExisting: Boolean,
-): AppResult = appResult(AppErrorCode.STORAGE, "Create measurement file") {
- val existing = directory.findFile(fileName)
- if (replaceExisting && existing != null) {
- if (existing.delete()) directory.createFile(normalizeMimeType(mimeType), fileName) else null
- } else {
- existing ?: directory.createFile(normalizeMimeType(mimeType), fileName)
+private fun findOrCreateFile(directory: DocumentFile, mimeType: String, fileName: String): AppResult =
+ appResult(AppErrorCode.STORAGE, "Create measurement file") {
+ directory.findFile(fileName) ?: directory.createFile(normalizeMimeType(mimeType), fileName)
}
-}
private fun storageFailure(operation: String): AppResult =
AppResult.failure(AppError(AppErrorCode.STORAGE, operation))
-private fun releaseOtherRootPermissions(context: Context, selectedUri: android.net.Uri) {
+private fun releaseOtherRootPermissions(context: Context, selectedUri: Uri) {
val resolver = context.contentResolver
resolver.persistedUriPermissions
.filter { it.uri != selectedUri }
diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeMeasurementImport.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeMeasurementImport.kt
new file mode 100644
index 0000000..5ebebcc
--- /dev/null
+++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeMeasurementImport.kt
@@ -0,0 +1,74 @@
+package com.tomasrepcik.sensorbox.core.storage
+
+import android.content.Context
+import androidx.documentfile.provider.DocumentFile
+import com.tomasrepcik.sensorbox.core.failure.AppErrorCode
+import com.tomasrepcik.sensorbox.core.failure.AppResult
+import com.tomasrepcik.sensorbox.core.failure.appResult
+import java.io.InputStream
+import java.util.UUID
+
+internal class NativeMeasurementImport(
+ private val context: Context,
+ private val root: DocumentFile,
+ private val measurementName: String,
+) : MeasurementImport {
+ private val staged = checkNotNull(root.createDirectory("$PENDING_MEASUREMENT_PREFIX${UUID.randomUUID()}"))
+ private var published = false
+
+ @Synchronized
+ override fun copy(fileName: String, mimeType: String, input: InputStream): AppResult =
+ appResult(AppErrorCode.STORAGE, "Stage watch file") {
+ check(!published)
+ val previous = staged.findFile(fileName)
+ check(previous == null || previous.delete()) { "Could not replace staged file" }
+ val file = checkNotNull(staged.createFile(mimeType, fileName))
+ checkNotNull(context.contentResolver.openOutputStream(file.uri, "wt")).use { output ->
+ input.copyTo(output)
+ }
+ }
+
+ @Synchronized
+ override fun commit(expectedFingerprint: String): AppResult =
+ appResult(AppErrorCode.STORAGE, "Publish complete watch measurement") {
+ check(!published)
+ check(fingerprint(staged) == expectedFingerprint) { "Watch measurement is incomplete or changed" }
+ val existing = root.findFile(measurementName)
+ if (existing != null) {
+ check(existing.isDirectory) { "Measurement destination is not a directory" }
+ if (fingerprint(existing) == expectedFingerprint) {
+ check(staged.delete()) { "Could not remove duplicate staged measurement" }
+ } else {
+ replace(existing)
+ }
+ } else {
+ check(staged.renameTo(measurementName)) { "Archive does not support publishing a complete folder" }
+ }
+ published = true
+ }
+
+ @Synchronized
+ override fun discard(): AppResult = appResult(AppErrorCode.STORAGE, "Discard incomplete watch measurement") {
+ check(published || !staged.exists() || staged.delete()) { "Could not remove incomplete watch measurement" }
+ }
+
+ private fun replace(existing: DocumentFile) {
+ check(existing.renameTo("$BACKUP_MEASUREMENT_PREFIX$measurementName")) {
+ "Could not preserve the previous measurement"
+ }
+ if (!staged.renameTo(measurementName)) {
+ check(existing.renameTo(measurementName)) { "Previous measurement awaits recovery on the next sync" }
+ error("Could not publish complete measurement")
+ }
+ published = true
+ // Failure to remove the backup is recoverable on the next sync.
+ appResult(AppErrorCode.STORAGE, "Remove previous measurement backup") { existing.delete() }
+ }
+
+ private fun fingerprint(directory: DocumentFile): String = measurementFingerprint(
+ directory.listFiles().map { file ->
+ check(file.isFile) { "Unexpected directory in watch measurement" }
+ checkNotNull(file.name) to { checkNotNull(context.contentResolver.openInputStream(file.uri)) }
+ },
+ )
+}
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