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

+ SensorBox app icon +

+ +

SensorBox

+ +

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

+ +

+ Android 10+ + Wear OS + Jetpack Compose +

+ +## See it in use + + + + + + + + + + + + + + +
SensorBox phone sensor selectionSensorBox recording setupSensorBox Wear OS dashboardSensorBox live watch sensor
Choose exactly what to capture.Set timing and recording rules.Record from the watch on its own.See a live signal before committing.
+ +## The idea + +SensorBox turns Android and Wear OS hardware into a field recorder. Pick the sensors, start a visible recording, and get plain CSV files in a folder you chose. No account, analytics SDK, or cloud storage sits in the middle. + +## What it does + +- Records available phone and watch sensors at Android sampling periods +- Adds foreground GPS samples when requested +- Runs through an explicit foreground service with safe stop paths +- Shows a live watch signal with a Compose chart +- Sends watch recordings to the phone through the Wear OS Channel API +- Writes phone measurements through Android's Storage Access Framework +- Keeps active recordings alive if the paired device disconnects + +## How it is built + +Phone and watch screens use MVI: + +```text +Composable -> Intent -> ViewModel -> use case -> repository -> State + Effect ``` -## Current feature set - -- Record available phone or watch sensors at Android sampling periods. -- Record foreground GPS samples alongside sensor data. -- Run recording work in an explicit foreground service with health/location service types. -- Stop safely from the app, watch, notification, low-battery policy, or a paired-device command. -- Preview a live watch sensor with a Compose-native chart. -- Stream watch recordings to the phone with the Wear OS Channel API. -- Store phone measurements in a user-selected recording archive through the Storage Access Framework. -- Follow system/dynamic color with light, dark, and custom fallback palettes. +| Module | Purpose | +| --- | --- | +| `app` | Phone UI, permissions, paired recording, and received watch files | +| `wear` | Watch UI, live charts, and standalone recording | +| `recording-core` | Pure Kotlin recording state machine and cleanup rules | +| `sensorservices` | Android sensor, GPS, foreground-service, and file adapters | +| `WearOsLib` | Versioned commands and Channel file transport | +| `core` / `core-common` | Preferences, storage, diagnostics, results, and errors | -## Architecture +Hilt wires the Android implementations behind testable interfaces. Each device owns its recording clock and local files. -The UI modules use unidirectional MVI: +## Build it -`Composable → Intent → ViewModel → use case → repository/service → State + Effect` - -UI launchers execute one-shot effects, while decisions and state transitions remain in workflow-owned ViewModels, reducers, and focused use cases. The phone shell owns navigation only. Hilt provides production dependencies and interfaces keep platform boundaries replaceable in tests. - -Modules: - -- `app`: phone Compose UI, workflow-owned MVI, paired-recording policy, permissions, and received watch files. -- `wear`: Wear Compose Material 3 UI, MVI, live charts, recording, and phone launch flow. -- `core-common`: platform-neutral `AppResult`, stable application errors, and diagnostics contracts. -- `recording-core`: pure Kotlin recording state machine, source roles, scheduling, and cleanup policy. -- `core`: Android DataStore preferences, document storage, local rotating diagnostics, and reusable test fixtures. -- `sensorservices`: Android recording adapters, foreground host, and linear sensor/GPS writers. It has no Wear dependency. -- `WearOsLib`: coroutine-based connectivity, strict protocol v5 JSON commands, and Channel file transport. App policy stays in `app` and `wear`. - -Paired phone/watch recording starts directly on each device. Commands are session-correlated and idempotent, and both devices own their local duration timer after starting. A lost connection does not stop an active recording; peer stop notifications are best effort. - -## Platform and toolchain - -- Android Gradle Plugin 9.3.2 and Gradle 9.7 -- Android compile/target SDK 37 (Android 17) -- Java 17 and Kotlin 2.4.10 -- Jetpack Compose Material 3 and Wear Compose Material 3 -- Hilt 2.60.1 -- DataStore Preferences 1.2.1 -- Detekt 2 with formatting rules and no baselines - -Every Kotlin function is checked at a maximum of 40 lines. Compose functions therefore also stay below the requested 60-line ceiling. - -## Build and quality checks - -Install JDK 17 and Android SDK 37, then run: +You need JDK 17 and Android SDK 37. A Wear OS device or emulator is only needed to run the watch app and paired tests. ```shell ./gradlew :app:assembleDebug :wear:assembleDebug ./gradlew testDebugUnitTest detekt ./gradlew :app:lintDebug :wear:lintDebug -./gradlew :app:validateDebugScreenshotTest :wear:validateDebugScreenshotTest -``` - -Instrumentation test sources can be compiled without a device: - -```shell -./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin ``` -Tests use Given/When/Then naming, reusable state/repository fixtures, coroutine test contexts, and Compose robots for end-to-end UI interactions. - -## Emulator integration tests - -The phone recording tests start the real foreground recording service, read the device sensors, control test GPS and battery state from Kotlin, and verify the generated files. Run the class directly from Android Studio or with Gradle: - -```shell -ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \ - "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.PhoneSensorRecordingEmulatorTest" -``` - -Standalone watch recording tests use the watch sensors and control test GPS and battery state from Kotlin. They do not require a paired phone: - -```shell -ANDROID_SERIAL=emulator-5554 ./gradlew :wear:connectedDebugAndroidTest \ - "-Pandroid.testInstrumentationRunnerArguments.class=com.tomasrepcik.sensorbox.emulator.WearSensorRecordingEmulatorTest" -``` - -The paired sync matrix sends CSV, JSON, text, empty, Unicode, overwrite, duplicate-name, ignored-extension, and 256 KiB fixtures through the real Wear OS Channel API. The phone verifies every destination and byte. Use a Google Play phone AVD and a Wear OS AVD, then pair them once with Android Studio's Pairing Assistant: - -```shell -ANDROID_HOME="$HOME/Library/Android/sdk" tools/emulator/run_wear_sync_test.sh -``` - -The runner detects one phone and one watch automatically; `PHONE_SERIAL` and `WEAR_SERIAL` remain available when several devices are connected. It builds and installs once, refreshes the ADB bridge after installation, and launches each scenario on both devices. When an emulator transport exposes its paired node but does not propagate static capabilities, the instrumentation-only repository falls back to that connected node; file transfer still uses the production Channel client and receiver. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected recording archive. - -The phone-driven paired recording test runs instrumentation only on the phone. It discovers a real Wear sensor, starts and stops a watch recording over the production message protocol, asks Wear OS to sync measurements, and verifies the transferred metadata and sensor samples on the phone: - -```shell -PHONE_SERIAL=emulator-5554 WEAR_SERIAL=emulator-5556 \ - tools/emulator/run_phone_paired_recording_test.sh -``` - -No Firebase project, Maps key, secrets file, or external storage permission is required. - -## Dependencies - -The former Flipper, AppIntro, Material Dialogs, NumberPicker, Android About Page, LicensesDialog, Toasty, GraphView, and custom countdown modules have been removed. Their replacements are native APIs or small project-owned Compose components. - -[Vico](https://github.com/patrykandpatrick/vico) is retained as the sole feature-level third-party UI library because it provides a maintained, Compose-native chart model and renderer suitable for the live Wear OS plot. AndroidX, Google Play services for Wear/location, Kotlin coroutines, Hilt, and Detekt remain infrastructure dependencies. - -## Privacy - -Recordings are started by the user and remain visible through a foreground-service notification. SensorBox writes each device's measurement locally and does not include upload, analytics, or crash-reporting SDKs. - -## License +No Firebase project, Maps key, secrets file, or external-storage permission is required. -SensorBox is licensed under the Apache License 2.0. See [LICENSE](LICENSE). +SensorBox is licensed under the [Apache License 2.0](LICENSE). diff --git a/WearOsLib/src/main/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 = mutableMapOf(), var durationStop: Job? = null, var failureMonitor: Job? = null, ) + private data class FailedSource(val source: RecordingSource, val failure: AppError) + private companion object { const val EVENT_BUFFER_SIZE = 16 + const val SOURCE_STOP_TIMEOUT_MILLIS = 5_000L } } diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt index 9addf67..7ac701f 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt @@ -1,7 +1,7 @@ package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult @JvmInline value class RecordingSessionId(val value: String) { @@ -66,7 +66,12 @@ data class RecordingStopContext(val reason: RecordingStopReason, val failures: L sealed interface RecordingEvent { val sessionId: RecordingSessionId - data class RecordingStarted(override val sessionId: RecordingSessionId) : RecordingEvent + data class SourceFailed( + override val sessionId: RecordingSessionId, + val sourceType: RecordingSourceType, + val failure: AppError, + val stopFailure: AppError? = null, + ) : RecordingEvent data class RecordingStopped( override val sessionId: RecordingSessionId, diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt index 9a5ee5e..11f84c7 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt @@ -1,7 +1,7 @@ package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt index 17b9819..d330ccb 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt @@ -9,6 +9,8 @@ sealed interface RecordingSessionState { val startedAtElapsedRealtime: Long, val sensorIds: List, val includesGps: Boolean, + val durationMillis: Long = 0L, + val sensorSamplingPeriod: Int = 0, ) : RecordingSessionState data object Stopping : RecordingSessionState diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt index 59efbf8..2effa57 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt @@ -1,11 +1,12 @@ package com.tomasrepcik.sensorbox.recording.session -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow + class RecordingSessionStore { private val mutableState = MutableStateFlow(RecordingSessionState.Idle) private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) diff --git a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt index cf27d67..d341d23 100644 --- a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt +++ b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt @@ -1,14 +1,18 @@ package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -31,9 +35,14 @@ class RecordingEngineTest { fun `Given unordered sources When recording starts Then sources start in stable order`() = runTest { // Given val calls = mutableListOf() - val engine = engine( - FakeSource(RecordingSourceType.GPS, calls), - FakeSource(RecordingSourceType.SENSOR, calls), + val engine = RecordingEngine( + sources = listOf( + FakeSource(RecordingSourceType.GPS, calls), + FakeSource(RecordingSourceType.SENSOR, calls), + ), + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + waitFor = { }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) val request = request( RecordingSourceSpec.Gps(10, 20), @@ -45,11 +54,13 @@ class RecordingEngineTest { // Then assertTrue(result.isSuccess) + assertTrue(calls.isEmpty()) + runCurrent() assertEquals(listOf("start:SENSOR", "start:GPS"), calls) } @Test - fun `Given a source start fails When recording starts Then attempted sources stop in reverse order`() = runTest { + fun `Given one source start fails When recording starts Then other source keeps recording`() = runTest { // Given val calls = mutableListOf() val engine = engine( @@ -60,16 +71,113 @@ class RecordingEngineTest { RecordingSourceSpec.Sensors(setOf(1), 0), RecordingSourceSpec.Gps(10, 20), ) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() // When val result = engine.start(request) + val event = failed.await() as RecordingEvent.SourceFailed // Then - assertTrue(result.isFailure) + assertTrue(result.isSuccess) + assertEquals(RecordingSourceType.GPS, event.sourceType) + assertEquals( + listOf("start:SENSOR", "start:GPS", "stop:GPS:SOURCE_FAILURE"), + calls, + ) + engine.stop(RecordingStopReason.USER_REQUEST) + } + + @Test + fun `Given source start never returns When recording starts Then next source still launches`() = runTest { + // Given + val calls = mutableListOf() + val engine = engine( + FakeSource(RecordingSourceType.SENSOR, calls, hangOnStart = true), + FakeSource(RecordingSourceType.GPS, calls), + ) + val request = request( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + // When + val result = engine.start(request) + + // Then + assertTrue(result.isSuccess) + assertEquals( + listOf("start:SENSOR", "start:GPS"), + calls, + ) + assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isSuccess) + } + + @Test + fun `Given source cleanup never returns When start fails Then failure is still published`() = runTest { + // Given + val calls = mutableListOf() + val engine = RecordingEngine( + sources = listOf( + FakeSource( + type = RecordingSourceType.SENSOR, + calls = calls, + startFails = true, + hangOnStop = true, + ), + ), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), + ) + val request = request(RecordingSourceSpec.Sensors(setOf(1), 0)) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() + + // When + val result = engine.start(request) + runCurrent() + advanceTimeBy(5_000L) + runCurrent() + val event = failed.await() as RecordingEvent.SourceFailed + + // Then + assertTrue(result.isSuccess) assertEquals( - listOf("stop:GPS:SOURCE_FAILURE", "stop:SENSOR:SOURCE_FAILURE"), - calls.takeLast(2), + listOf("start:SENSOR", "stop:SENSOR:SOURCE_FAILURE"), + calls, ) + assertEquals("Stop SENSOR recording source", event.stopFailure?.operation) + engine.stop(RecordingStopReason.USER_REQUEST) + } + + @Test + fun `Given measurement metadata start fails When recording starts Then sensor still launches`() = runTest { + // Given + val calls = mutableListOf() + val engine = engine( + FakeSource(RecordingSourceType.SESSION_METADATA, calls, startFails = true), + FakeSource(RecordingSourceType.SENSOR, calls), + ) + val request = request( + RecordingSourceSpec.SessionMetadata, + RecordingSourceSpec.Sensors(setOf(1), 0), + ) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() + + // When + val result = engine.start(request) + val event = failed.await() as RecordingEvent.SourceFailed + + // Then + assertTrue(result.isSuccess) + assertEquals(RecordingSourceType.SESSION_METADATA, event.sourceType) + assertEquals( + listOf("start:SESSION_METADATA", "stop:SESSION_METADATA:SOURCE_FAILURE", "start:SENSOR"), + calls, + ) + engine.stop(RecordingStopReason.USER_REQUEST) } @Test @@ -96,7 +204,7 @@ class RecordingEngineTest { } @Test - fun `Given runtime and stop failures When session stops Then session receives every failure`() = runTest { + fun `Given runtime and stop failures When a source fails Then both failures are published`() = runTest { // Given val calls = mutableListOf() val session = FakeSource(RecordingSourceType.SESSION_METADATA, calls) @@ -105,18 +213,23 @@ class RecordingEngineTest { sources = listOf(session, sensor), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) engine.start(request(RecordingSourceSpec.SessionMetadata, RecordingSourceSpec.Sensors(setOf(1), 0))) + val failed = async { engine.events.first { it is RecordingEvent.SourceFailed } } + runCurrent() // When sensor.reportFailure() runCurrent() // Then + val event = failed.await() as RecordingEvent.SourceFailed assertEquals( listOf("Record SENSOR", "Stop SENSOR"), - session.stopContext?.failures?.map(AppError::operation), + listOf(event.failure.operation, event.stopFailure?.operation), ) + assertEquals(null, session.stopContext) } @Test @@ -141,9 +254,10 @@ class RecordingEngineTest { // Given val calls = mutableListOf() val engine = RecordingEngine( - sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls)), + sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls, yieldOnStop = true)), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) val request = request(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) @@ -157,16 +271,18 @@ class RecordingEngineTest { } @Test - fun `Given a running source When it reports failure Then the recording stops`() = runTest { + fun `Given one running source fails When another source remains Then recording continues`() = runTest { // Given val calls = mutableListOf() + val metadata = FakeSource(RecordingSourceType.SESSION_METADATA, calls) val source = FakeSource(RecordingSourceType.SENSOR, calls) val engine = RecordingEngine( - sources = listOf(source), + sources = listOf(metadata, source), scope = backgroundScope, waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), ) - val request = request(RecordingSourceSpec.Sensors(setOf(1), 0)) + val request = request(RecordingSourceSpec.SessionMetadata, RecordingSourceSpec.Sensors(setOf(1), 0)) engine.start(request) // When @@ -175,13 +291,47 @@ class RecordingEngineTest { // Then assertEquals(1, calls.count { it == "stop:SENSOR:SOURCE_FAILURE" }) - assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isFailure) + assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isSuccess) + assertEquals(1, calls.count { it == "stop:SESSION_METADATA:USER_REQUEST" }) + assertEquals(0, calls.count { it == "stop:SENSOR:USER_REQUEST" }) + } + + @Test + fun `Given every running source fails When recording stops Then metadata only session remains valid`() = runTest { + // Given + val calls = mutableListOf() + val sensor = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls) + val engine = RecordingEngine( + sources = listOf(sensor, gps), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + sourceStartDispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler), + ) + engine.start( + request( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ), + ) + + // When + sensor.reportFailure() + gps.reportFailure() + runCurrent() + val result = engine.stop(RecordingStopReason.USER_REQUEST) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, calls.count { it == "stop:SENSOR:SOURCE_FAILURE" }) + assertEquals(1, calls.count { it == "stop:GPS:SOURCE_FAILURE" }) } private fun engine(vararg sources: RecordingSource) = RecordingEngine( sources = sources.toList(), scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), waitFor = { }, + sourceStartDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, ) private fun request(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingRequest( @@ -196,6 +346,9 @@ private class FakeSource( private val calls: MutableList, private val startFails: Boolean = false, private val stopFails: Boolean = false, + private val hangOnStart: Boolean = false, + private val hangOnStop: Boolean = false, + private val yieldOnStop: Boolean = false, ) : RecordingSource { private val mutableFailures = MutableSharedFlow(replay = 1) override val failures: Flow = mutableFailures @@ -204,12 +357,15 @@ private class FakeSource( override suspend fun start(spec: RecordingSourceSpec): AppResult { calls += "start:$type" + if (hangOnStart) awaitCancellation() return if (startFails) failure("Start $type") else AppResult.success(Unit) } override suspend fun stop(context: RecordingStopContext): AppResult { + if (yieldOnStop) yield() stopContext = context calls += "stop:$type:${context.reason}" + if (hangOnStop) awaitCancellation() return if (stopFails) failure("Stop $type") else AppResult.success(Unit) } diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index 1eff022..a24d6d0 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.coroutines.test) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) } hilt { diff --git a/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt b/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt new file mode 100644 index 0000000..2c23ecf --- /dev/null +++ b/sensorservices/src/androidTest/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiverTest.kt @@ -0,0 +1,115 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer +import com.google.android.gms.location.ActivityRecognitionResult +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionEvent +import com.google.android.gms.location.ActivityTransitionResult +import com.google.android.gms.location.DetectedActivity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class ActivityRecognitionReceiverTest { + private var registration: ActivityRecognitionCallbackRegistration? = null + + @After + fun unregisterCallbacks() { + registration?.let(ActivityRecognitionCallbacks::unregister) + registration = null + } + + @Test + fun givenGoogleActivityResultWhenPendingIntentIsSentThenConfidenceCallbackReceivesIt() { + // Given + val context = ApplicationProvider.getApplicationContext() + val callbackReceived = CountDownLatch(1) + var update: ActivityUpdate? = null + registration = ActivityRecognitionCallbacks.register( + onUpdate = { received -> + update = received + callbackReceived.countDown() + }, + onTransitions = {}, + ) + val pendingIntent = createActivityRecognitionPendingIntent( + context = context, + requestCode = UPDATE_TEST_REQUEST_CODE, + action = ACTIVITY_UPDATE_ACTION, + ) + val result = ActivityRecognitionResult( + listOf(DetectedActivity(DetectedActivity.WALKING, 83)), + 1L, + 123L, + ) + val resultIntent = Intent().putExtra(ACTIVITY_RESULT_EXTRA, result) + + // When + pendingIntent.send(context, 0, resultIntent) + + // Then + assertTrue(callbackReceived.await(2, TimeUnit.SECONDS)) + assertEquals( + ActivityUpdate(123L, listOf(0, 0, 83, 0, 0, 0, 0, 0)), + update, + ) + pendingIntent.cancel() + } + + @Test + fun givenGoogleTransitionResultWhenReceiverHandlesItThenTransitionCallbackReceivesIt() { + // Given + var transitions: List? = null + registration = ActivityRecognitionCallbacks.register( + onUpdate = {}, + onTransitions = { received -> transitions = received }, + ) + val result = ActivityTransitionResult( + listOf( + ActivityTransitionEvent( + DetectedActivity.WALKING, + ActivityTransition.ACTIVITY_TRANSITION_ENTER, + 456L, + ), + ), + ) + val resultIntent = Intent(ACTIVITY_TRANSITION_ACTION) + SafeParcelableSerializer.serializeToIntentExtra( + result, + resultIntent, + ACTIVITY_TRANSITION_RESULT_EXTRA, + ) + + // When + ActivityRecognitionReceiver().onReceive(null, resultIntent) + + // Then + assertEquals( + listOf( + ActivityTransitionSample( + elapsedRealtimeNanos = 456L, + activityType = DetectedActivity.WALKING, + transitionType = ActivityTransition.ACTIVITY_TRANSITION_ENTER, + ), + ), + transitions, + ) + } + + private companion object { + const val UPDATE_TEST_REQUEST_CODE = 4_571 + const val ACTIVITY_RESULT_EXTRA = + "com.google.android.location.internal.EXTRA_ACTIVITY_RESULT" + const val ACTIVITY_TRANSITION_RESULT_EXTRA = + "com.google.android.location.internal.EXTRA_ACTIVITY_TRANSITION_RESULT" + } +} diff --git a/sensorservices/src/main/AndroidManifest.xml b/sensorservices/src/main/AndroidManifest.xml index c291b4e..1f6ddfe 100644 --- a/sensorservices/src/main/AndroidManifest.xml +++ b/sensorservices/src/main/AndroidManifest.xml @@ -5,18 +5,27 @@ - + + + + + android:exported="false"> + + diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt similarity index 90% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt index 5377fdd..f7ce948 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/notification/Notify.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.services +package com.tomasrepcik.sensorbox.recordinghost.notification import android.app.Notification import android.app.NotificationChannel @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import com.tomasrepcik.sensorbox.sensorservices.R object Notify { @@ -30,6 +31,9 @@ object Notify { .setContentIntent(createOpenAppAction(context)) .addAction(R.drawable.ic_stop, context.getString(R.string.text_stop), stopAction) .build() + .apply { + flags = flags or Notification.FLAG_NO_CLEAR + } } private fun createOpenAppAction(context: Context): PendingIntent? { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt similarity index 92% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt index c20e461..3042dc1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingIntentFactory.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingIntentFactory.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.intent +package com.tomasrepcik.sensorbox.recordinghost.request import android.content.Context import android.content.Intent -import com.tomasrepcik.sensorbox.core.time.ClockFormats +import com.tomasrepcik.sensorbox.core.format.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt similarity index 95% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt index c8fb454..42c02d7 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/RecordingRequest.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/request/RecordingRequest.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.sensorservices.intent +package com.tomasrepcik.sensorbox.recordinghost.request import android.content.Intent import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService import java.util.UUID data class RecordingRequest( diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt similarity index 74% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt index 2c8a024..83b4ca8 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingHostResources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostResources.kt @@ -1,31 +1,34 @@ -package com.tomasrepcik.sensorbox.sensorservices.services +package com.tomasrepcik.sensorbox.recordinghost.session +import android.annotation.SuppressLint import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.os.Build import android.os.PowerManager -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.notification.Notify +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds internal class RecordingHostResources( private val service: Service, private val scope: CoroutineScope, private val requestStop: (RecordingStopReason) -> Unit, private val playAlarm: () -> Unit, -) { +) : RecordingHostEnvironment { private var wakeLock: PowerManager.WakeLock? = null private var batteryReceiverRegistered = false private var alarmJobs: List = emptyList() @@ -36,32 +39,36 @@ internal class RecordingHostResources( } } - fun start(request: RecordingRequest) { + override fun start(request: RecordingRequest) { promoteToForeground(request) if (request.requiresWakeLock) acquireWakeLock() if (request.stopOnLowBattery) registerLowBatteryReceiver() } - fun scheduleAlarms(offsetsSeconds: List) { + override fun scheduleAlarms(offsetsSeconds: List) { alarmJobs = offsetsSeconds.distinct().sorted().map { seconds -> scope.launch { - delay(seconds * 1_000L) + delay((seconds * 1_000L).milliseconds) playAlarm() } } } - fun release(): AppResult { + override fun release(): AppResult { alarmJobs.forEach(Job::cancel) alarmJobs = emptyList() return listOf(releaseWakeLock(), unregisterLowBatteryReceiver()) .combineAppResults(AppErrorCode.RECORDING, "Release recording host resources") } - fun removeNotification() { + override fun removeNotification() { service.stopForeground(Service.STOP_FOREGROUND_REMOVE) } + override fun stopService() { + service.stopSelf() + } + private fun promoteToForeground(request: RecordingRequest) { val notification = Notify.createRecordingNotification(service) if (Build.VERSION.SDK_INT >= 34) { @@ -71,13 +78,16 @@ internal class RecordingHostResources( } } + @SuppressLint("InlinedApi") private fun foregroundTypes(request: RecordingRequest): Int = if (request.includesGps) { - FOREGROUND_SERVICE_TYPE_HEALTH or FOREGROUND_SERVICE_TYPE_LOCATION + FOREGROUND_SERVICE_TYPE_SPECIAL_USE or FOREGROUND_SERVICE_TYPE_LOCATION } else { - FOREGROUND_SERVICE_TYPE_HEALTH + FOREGROUND_SERVICE_TYPE_SPECIAL_USE } + @SuppressLint("WakelockTimeout") private fun acquireWakeLock() { + // Recordings may be indefinite. The recording host releases this lock when its foreground session ends. val powerManager = service.getSystemService(PowerManager::class.java) wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt new file mode 100644 index 0000000..fc7352b --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSession.kt @@ -0,0 +1,175 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.recording.RecordingEvent +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +internal interface RecordingSessionExecution { + val events: SharedFlow + + suspend fun start(): AppResult + + suspend fun stop(reason: RecordingStopReason): AppResult + + fun annotate(timestampMillis: Long, text: String): AppResult + + fun playAlarm(): AppResult +} + +internal interface RecordingHostEnvironment { + fun start(request: RecordingRequest) + + fun scheduleAlarms(offsetsSeconds: List) + + fun release(): AppResult + + fun removeNotification() + + fun stopService() +} + +internal class RecordingHostSession( + private val request: RecordingRequest, + private val execution: RecordingSessionExecution, + private val environment: RecordingHostEnvironment, + private val sessionStore: RecordingSessionStore, + private val diagnosticLogger: DiagnosticLogger, + private val scope: CoroutineScope, + private val elapsedRealtimeMillis: () -> Long, +) { + private var eventJob: Job? = null + private var isFinishing = false + + suspend fun start(): AppResult { + val hostStart = appResult(AppErrorCode.RECORDING, "Start recording foreground host") { + environment.start(request) + } + if (hostStart.isFailure) { + finish(RecordingSessionStopReason.SOURCE_FAILURE, hostStart) + return hostStart + } + + observeExecution() + val result = execution.start() + if (result.isFailure) { + finish(RecordingSessionStopReason.SOURCE_FAILURE, result) + } else { + markRecordingAsRunning() + } + return result + } + + fun annotate(timestampMillis: Long, text: String): AppResult = execution.annotate(timestampMillis, text) + + fun requestStop(reason: RecordingStopReason) { + if (isFinishing) return + sessionStore.markStopping() + scope.launch { execution.stop(reason) } + } + + fun destroy() { + if (isFinishing) { + eventJob?.cancel() + return + } + + isFinishing = true + val executionResult = runBlocking(Dispatchers.IO) { + execution.stop(RecordingStopReason.PLATFORM_DESTROYED) + } + val environmentResult = environment.release() + sessionStore.markIdle() + sessionStore.publishStopped( + request.sessionId, + RecordingSessionStopReason.SERVICE_DESTROYED, + listOf(executionResult, environmentResult).combineAppResults( + AppErrorCode.RECORDING, + "Destroy recording foreground host", + ), + ) + eventJob?.cancel() + } + + private fun observeExecution() { + eventJob?.cancel() + eventJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + execution.events.collect { event -> + when (event) { + is RecordingEvent.SourceFailed -> recordSourceFailure(event) + is RecordingEvent.RecordingStopped -> finish(event.reason.toSessionReason(), event.result) + } + } + } + } + + private fun recordSourceFailure(event: RecordingEvent.SourceFailed) { + diagnosticLogger.record(event.failure.toDiagnosticEvent()) + event.stopFailure?.let { diagnosticLogger.record(it.toDiagnosticEvent()) } + } + + private fun markRecordingAsRunning() { + sessionStore.markRunning( + RecordingSessionState.Running( + sessionId = request.sessionId, + folderName = request.folderName, + startedAtElapsedRealtime = elapsedRealtimeMillis(), + sensorIds = request.sensorIds.toList(), + includesGps = request.includesGps, + durationMillis = request.durationMillis, + sensorSamplingPeriod = request.sensorSamplingPeriod, + ), + ) + environment.scheduleAlarms(request.alarmOffsetsSeconds) + } + + private fun finish(reason: RecordingSessionStopReason, executionResult: AppResult) { + if (isFinishing) return + isFinishing = true + + val hostResult = finishHost() + val result = listOf(executionResult, hostResult) + .combineAppResults(AppErrorCode.RECORDING, "Finish recording foreground host") + sessionStore.publishStopped(request.sessionId, reason, result) + eventJob?.cancel() + eventJob = null + } + + private fun finishHost(): AppResult { + val results = mutableListOf>() + results += environment.release() + results += appResult(AppErrorCode.RECORDING, "Publish idle recording state") { + sessionStore.markIdle() + } + results += appResult(AppErrorCode.RECORDING, "Remove recording notification") { + environment.removeNotification() + } + results += appResult(AppErrorCode.RECORDING, "Stop recording service instance") { + environment.stopService() + } + return results.combineAppResults(AppErrorCode.RECORDING, "Finish recording host resources") + } + + private fun RecordingStopReason.toSessionReason(): RecordingSessionStopReason = when (this) { + RecordingStopReason.USER_REQUEST -> RecordingSessionStopReason.USER_REQUEST + RecordingStopReason.DURATION_EXPIRED -> RecordingSessionStopReason.DURATION_EXPIRED + RecordingStopReason.LOW_BATTERY -> RecordingSessionStopReason.LOW_BATTERY + RecordingStopReason.SOURCE_FAILURE -> RecordingSessionStopReason.SOURCE_FAILURE + RecordingStopReason.PLATFORM_DESTROYED -> RecordingSessionStopReason.SERVICE_DESTROYED + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt new file mode 100644 index 0000000..8831f9c --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionFactory.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import android.app.Service +import android.os.SystemClock +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.session.ServiceController +import com.tomasrepcik.sensorbox.recordinghost.storage.StorageHandler +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +internal class RecordingHostSessionFactory @Inject constructor( + private val storage: StorageHandler, + private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, + private val sessionStore: RecordingSessionStore, +) { + fun create(service: Service, request: RecordingRequest, scope: CoroutineScope): RecordingHostSession { + val execution = ServiceController( + context = service, + request = request, + scope = scope, + storage = storage, + diagnosticLogger = diagnosticLogger, + clock = clock, + ) + lateinit var session: RecordingHostSession + val environment = RecordingHostResources( + service = service, + scope = scope, + requestStop = { reason -> session.requestStop(reason) }, + playAlarm = { execution.playAlarm() }, + ) + session = RecordingHostSession( + request = request, + execution = execution, + environment = environment, + sessionStore = sessionStore, + diagnosticLogger = diagnosticLogger, + scope = scope, + elapsedRealtimeMillis = SystemClock::elapsedRealtime, + ) + return session + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt new file mode 100644 index 0000000..a9fc247 --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingService.kt @@ -0,0 +1,100 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class RecordingService : Service() { + @Inject + lateinit var sessionStore: RecordingSessionStore + + @Inject + internal lateinit var sessionFactory: RecordingHostSessionFactory + + @Inject + lateinit var epochClock: EpochClock + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var hostSession: RecordingHostSession? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP_RECORDING -> hostSession?.requestStop(RecordingStopReason.USER_REQUEST) + + ACTION_ANNOTATE -> hostSession?.annotate( + intent.getLongExtra(ANNOTATION_TIME, epochClock.nowMillis()), + intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), + ) + + else -> intent?.takeIf { hostSession == null }?.let(::startRecordingHost) + } + return START_NOT_STICKY + } + + private fun startRecordingHost(intent: Intent) { + appResult(AppErrorCode.RECORDING, "Start recording foreground host") { + val request = RecordingRequest.from(intent) + require(request.sessionId.isNotBlank()) { "Recording session ID is missing" } + sessionFactory.create(this, request, serviceScope).also { session -> + hostSession = session + serviceScope.launch { session.start() } + } + }.onFailure { error -> + sessionStore.markIdle() + sessionStore.publishStopped( + sessionId = "", + reason = RecordingSessionStopReason.SOURCE_FAILURE, + result = AppResult.failure(error), + ) + stopSelf() + } + } + + override fun onDestroy() { + hostSession?.destroy() + hostSession = null + serviceScope.cancel() + super.onDestroy() + } + + companion object { + const val SESSION_ID = "SESSION_ID" + const val FOLDER_NAME = "FOLDER_NAME" + const val INTERNAL_STORAGE = "INTERNAL_STORAGE" + const val ANDROID_SENSORS = "ANDROID_SENSORS" + const val ANDROID_SENSORS_SPEED = "ANDROID_SENSORS_SPEED" + const val GPS = "GPS_MEASURE" + const val STOP_ON_LOW_BATTERY = "STOP_ON_LOW_BATTERY" + const val USE_WAKE_LOCK = "USE_WAKE_LOCK" + const val GPS_INTERVAL_SECONDS = "GPS_INTERVAL_SECONDS" + const val GPS_DISTANCE_METERS = "GPS_DISTANCE_METERS" + const val DURATION_MILLIS = "DURATION_MILLIS" + const val NOTES = "NOTES" + const val ALARM_OFFSETS_SECONDS = "ALARM_OFFSETS_SECONDS" + const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" + const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" + const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" + const val ACTION_ANNOTATE = "com.tomasrepcik.sensorbox.action.ANNOTATE_MEASUREMENT" + const val ANNOTATION_TIME = "ANNOTATION_TIME" + const val ANNOTATION_TEXT = "ANNOTATION_TEXT" + const val ACTION_STOP_RECORDING = "com.tomasrepcik.sensorbox.action.STOP_RECORDING" + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt similarity index 60% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt index 4a28f95..c3d7476 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/session/ServiceController.kt @@ -1,16 +1,17 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.session import android.content.Context -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingEngine import com.tomasrepcik.sensorbox.recording.RecordingEvent import com.tomasrepcik.sensorbox.recording.RecordingSessionId import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.AndroidRecordingSources +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import com.tomasrepcik.sensorbox.recording.RecordingRequest as EngineRecordingRequest @@ -19,10 +20,10 @@ internal class ServiceController( context: Context, private val request: RecordingRequest, scope: CoroutineScope, - storage: MeasurementStorage, + private val storage: MeasurementStorage, diagnosticLogger: DiagnosticLogger, clock: EpochClock, -) { +) : RecordingSessionExecution { private val androidSources = AndroidRecordingSources(context, request, storage, diagnosticLogger, clock) private val sessionId = RecordingSessionId(request.sessionId) private val engine = RecordingEngine( @@ -30,15 +31,24 @@ internal class ServiceController( scope = scope, ) - val events: SharedFlow = engine.events + override val events: SharedFlow = engine.events - suspend fun start(): AppResult = engine.start(request.toEngineRequest(sessionId)) + override suspend fun start(): AppResult { + val directory = storage.createMeasurementDirectory( + request.folderName, + request.useInternalStorage, + ) + if (directory is AppResult.Failure) return directory + + return engine.start(request.toEngineRequest(sessionId)) + } - suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(reason) + override suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(reason) - fun annotate(timestampMillis: Long, text: String): AppResult = androidSources.annotate(timestampMillis, text) + override fun annotate(timestampMillis: Long, text: String): AppResult = + androidSources.annotate(timestampMillis, text) - fun playAlarm(): AppResult = androidSources.playAlarm() + override fun playAlarm(): AppResult = androidSources.playAlarm() private fun RecordingRequest.toEngineRequest(sessionId: RecordingSessionId): EngineRecordingRequest { val specs = buildList { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt similarity index 53% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt index 351082b..8e021bf 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/AndroidRecordingSources.kt @@ -1,19 +1,25 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources import android.content.Context +import android.content.pm.PackageManager import android.hardware.SensorManager import com.google.android.gms.location.LocationServices -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingSource -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.AndroidActivityRecognitionPlatform -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GpsRecording -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorRecording -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.AndroidActivityRecognitionPlatform +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.sessionmetadata.SessionMetadataRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecordingSource +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class AndroidRecordingSources( context: Context, @@ -23,7 +29,13 @@ internal class AndroidRecordingSources( clock: EpochClock, ) { private val sensorManager = context.getSystemService(SensorManager::class.java) - private val sessionMetadata = SessionMetadataRecordingSource(request, storage, clock, sensorManager) + private val sessionMetadata = SessionMetadataRecordingSource( + request, + storage, + clock, + sensorManager, + if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)) "watch" else "phone", + ) val sources: List = listOf( sessionMetadata, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt similarity index 50% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt index 3cc809e..e7855e4 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/RecordingSourceSpecValidation.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceSpecValidation.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSourceType internal fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt new file mode 100644 index 0000000..d56001f --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatform.kt @@ -0,0 +1,175 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityRecognitionClient +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionRequest +import com.google.android.gms.location.DetectedActivity +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import kotlinx.coroutines.tasks.await + +internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) + +internal data class ActivityTransitionSample( + val elapsedRealtimeNanos: Long, + val activityType: Int, + val transitionType: Int, +) + +internal interface ActivityRecognitionPlatform { + suspend fun start( + periodSeconds: Int, + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult + + suspend fun stop(): AppResult +} + +internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { + private var client: ActivityRecognitionClient? = null + private var updatesPendingIntent: PendingIntent? = null + private var transitionsPendingIntent: PendingIntent? = null + private var callbacks: ActivityRecognitionCallbackRegistration? = null + + @SuppressLint("MissingPermission") + override suspend fun start( + periodSeconds: Int, + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult { + if (!hasPermission()) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) + } + val prepared = prepareResources(onUpdate, onTransitions) + if (prepared.isFailure) return prepared + return suspendAppResult(AppErrorCode.RECORDING, "Start activity recognition") { + val activeClient = checkNotNull(client) { "Activity recognition client is missing" } + val updates = checkNotNull(updatesPendingIntent) { "Activity update request is missing" } + val transitions = checkNotNull(transitionsPendingIntent) { "Activity transition request is missing" } + activeClient.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, updates).await() + activeClient.requestActivityTransitionUpdates( + ActivityTransitionRequest(activityTransitions), + transitions, + ).await() + } + } + + private fun prepareResources( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult = appResult(AppErrorCode.RECORDING, "Initialize activity recognition resources") { + client = ActivityRecognition.getClient(context) + updatesPendingIntent = createActivityRecognitionPendingIntent( + context, + UPDATES_REQUEST_CODE, + ACTIVITY_UPDATE_ACTION, + ) + transitionsPendingIntent = createActivityRecognitionPendingIntent( + context, + TRANSITIONS_REQUEST_CODE, + ACTIVITY_TRANSITION_ACTION, + ) + callbacks = ActivityRecognitionCallbacks.register(onUpdate, onTransitions) + } + + @SuppressLint("MissingPermission") + override suspend fun stop(): AppResult { + val results = mutableListOf>() + updatesPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity updates") { + client?.removeActivityUpdates(pendingIntent)?.await() + } + } + transitionsPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity transitions") { + client?.removeActivityTransitionUpdates(pendingIntent)?.await() + } + } + results += appResult(AppErrorCode.RECORDING, "Release activity recognition resources") { + callbacks?.let(ActivityRecognitionCallbacks::unregister) + client = null + updatesPendingIntent = null + transitionsPendingIntent = null + callbacks = null + } + return results.combineAppResults(AppErrorCode.RECORDING, "Pause activity recognition") + } + + private fun hasPermission(): Boolean { + val permission = if (Build.VERSION.SDK_INT >= 29) { + Manifest.permission.ACTIVITY_RECOGNITION + } else { + LEGACY_ACTIVITY_RECOGNITION_PERMISSION + } + return ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + } + + private companion object { + const val UPDATES_REQUEST_CODE = 457 + const val TRANSITIONS_REQUEST_CODE = 1_654 + } +} + +internal const val ACTIVITY_UPDATE_ACTION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_UPDATE" +internal const val ACTIVITY_TRANSITION_ACTION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" +internal const val LEGACY_ACTIVITY_RECOGNITION_PERMISSION = + "com.google.android.gms.permission.ACTIVITY_RECOGNITION" + +internal val ACTIVITY_CONFIDENCE_TYPES = intArrayOf( + DetectedActivity.STILL, + DetectedActivity.ON_FOOT, + DetectedActivity.WALKING, + DetectedActivity.RUNNING, + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_BICYCLE, + DetectedActivity.UNKNOWN, + DetectedActivity.TILTING, +) + +internal val ACTIVITY_TRANSITION_TYPES = intArrayOf( + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_FOOT, + DetectedActivity.RUNNING, + DetectedActivity.WALKING, + DetectedActivity.ON_BICYCLE, + DetectedActivity.STILL, +) + +private val activityTransitions = ACTIVITY_TRANSITION_TYPES.flatMap { activity -> + listOf( + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) + .build(), + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) + .build(), + ) +} + +internal fun createActivityRecognitionPendingIntent( + context: Context, + requestCode: Int, + action: String, +): PendingIntent = PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, ActivityRecognitionReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, +) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt new file mode 100644 index 0000000..507e3aa --- /dev/null +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionReceiver.kt @@ -0,0 +1,68 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.google.android.gms.location.ActivityRecognitionResult +import com.google.android.gms.location.ActivityTransitionResult + +class ActivityRecognitionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let(ActivityRecognitionCallbacks::receive) + } +} + +internal class ActivityRecognitionCallbackRegistration( + val onUpdate: (ActivityUpdate) -> Unit, + val onTransitions: (List) -> Unit, +) + +internal object ActivityRecognitionCallbacks { + @Volatile + private var active: ActivityRecognitionCallbackRegistration? = null + + fun register( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): ActivityRecognitionCallbackRegistration = + ActivityRecognitionCallbackRegistration(onUpdate, onTransitions).also { registration -> + active = registration + } + + fun unregister(registration: ActivityRecognitionCallbackRegistration) { + synchronized(this) { + if (active === registration) active = null + } + } + + fun receive(intent: Intent) { + val registration = active ?: return + when (intent.action) { + ACTIVITY_UPDATE_ACTION -> receiveUpdate(intent, registration) + ACTIVITY_TRANSITION_ACTION -> receiveTransitions(intent, registration) + } + } + + private fun receiveUpdate(intent: Intent, registration: ActivityRecognitionCallbackRegistration) { + val result = ActivityRecognitionResult.extractResult(intent) ?: return + registration.onUpdate( + ActivityUpdate( + elapsedRealtimeMillis = result.elapsedRealtimeMillis, + confidences = ACTIVITY_CONFIDENCE_TYPES.map(result::getActivityConfidence), + ), + ) + } + + private fun receiveTransitions(intent: Intent, registration: ActivityRecognitionCallbackRegistration) { + val result = ActivityTransitionResult.extractResult(intent) ?: return + registration.onTransitions( + result.transitionEvents.map { transition -> + ActivityTransitionSample( + elapsedRealtimeNanos = transition.elapsedRealTimeNanos, + activityType = transition.activityType, + transitionType = transition.transitionType, + ) + }, + ) + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt similarity index 90% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt index 5aea36c..67689d1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecording.kt @@ -1,12 +1,12 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt similarity index 69% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt index 2becec0..4e36d28 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecordingSource.kt @@ -1,14 +1,15 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionPlatform -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecognitionPlatform +import com.tomasrepcik.sensorbox.recordinghost.sources.activity.ActivityRecognitionRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class ActivityRecordingSource( private val request: RecordingRequest, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt similarity index 93% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt index fa5a1ca..312e428 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GPSHandler.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.sources.gps import android.annotation.SuppressLint import android.location.Location @@ -11,10 +11,10 @@ import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap @SuppressLint("MissingPermission") class GPSHandler(private val locationClient: FusedLocationProviderClient? = null) : LocationCallback() { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt similarity index 85% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt index 83d783e..bf64e72 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GpsRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecording.kt @@ -1,16 +1,15 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.gps import android.location.Location -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -44,7 +43,7 @@ internal class GpsRecording( val streamResult = storage.openMeasurementFile( folderName = folderName, mimeType = "text/csv", - fileName = "gps.csv", + fileName = "GPS.csv", useInternalStorage = useInternalStorage, ) val output = streamResult.getOrNull() diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt similarity index 75% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt index f5bb233..ceec0dc 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/gps/GpsRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.gps -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GpsRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec internal class GpsRecordingSource(private val request: RecordingRequest, private val recording: GpsRecording) : RecordingSource { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt similarity index 89% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt index 05c9322..fd6e42a 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolder.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt similarity index 82% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt index 6294e1b..67d08fe 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecording.kt @@ -1,18 +1,18 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats -import com.tomasrepcik.sensorbox.sensorservices.types.SensorHolder -import com.tomasrepcik.sensorbox.sensorservices.types.SensorSpec +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorHolder +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorSpec +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt similarity index 75% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt index 42bd070..e7ab7b3 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording internal class SensorRecordingSource(private val request: RecordingRequest, private val recording: SensorRecording) : RecordingSource { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt similarity index 96% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt index f1150d5..bcfd268 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpec.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt similarity index 73% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt index 831109d..582e558 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionMetadataRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/sessionmetadata/SessionMetadataRecordingSource.kt @@ -1,37 +1,39 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.sessionmetadata import android.hardware.SensorManager import android.media.AudioManager import android.media.ToneGenerator -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.MeasurementMetadataWriter -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementMetadataWriter +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage internal class SessionMetadataRecordingSource( private val request: RecordingRequest, - private val storage: MeasurementStorage, + storage: MeasurementStorage, clock: EpochClock, sensorManager: SensorManager, + device: String, ) : RecordingSource { - private val metadataWriter = MeasurementMetadataWriter(storage, clock, sensorManager) + private val metadataWriter = MeasurementMetadataWriter(storage, clock, sensorManager, device) private var toneGenerator: ToneGenerator? = null override val type = RecordingSourceType.SESSION_METADATA override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.SessionMetadata) { - storage.createMeasurementDirectory(request.folderName, request.useInternalStorage) - .onSuccess { metadataWriter.start(request) } + metadataWriter.start(request) + AppResult.success(Unit) } else { invalidSpec(type) } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt similarity index 86% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt index 307c558..705463a 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotionRecording.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecording.kt @@ -1,17 +1,17 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion import android.hardware.Sensor import android.hardware.SensorManager import android.hardware.TriggerEvent import android.hardware.TriggerEventListener -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.combineAppResults +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt similarity index 71% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt index af200fe..571b10d 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/sources/significantmotion/SignificantMotionRecordingSource.kt @@ -1,12 +1,13 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController +package com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotionRecording -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.invalidSpec +import com.tomasrepcik.sensorbox.recordinghost.sources.significantmotion.SignificantMotionRecording internal class SignificantMotionRecordingSource( private val request: RecordingRequest, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt similarity index 89% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt index 15084e9..4709916 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataWriter.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataWriter.kt @@ -1,22 +1,20 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.storage import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.flatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.core.time.ClockFormats +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap +import com.tomasrepcik.sensorbox.core.failure.withAppError +import com.tomasrepcik.sensorbox.core.format.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingStopContext -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** Collects session metadata and writes it once when the recording is closed. */ @@ -24,6 +22,7 @@ internal class MeasurementMetadataWriter( private val storage: MeasurementStorage, private val clock: EpochClock, private val sensorManager: SensorManager, + private val device: String, ) { private var request: RecordingRequest? = null private var startedAtMillis: Long = 0L @@ -67,6 +66,8 @@ internal class MeasurementMetadataWriter( type = RECORDING_TYPE, date = ClockFormats.metadataTimestamp(startedAtMillis), folder = active.folderName, + sessionId = active.sessionId, + device = device, notes = active.notes, annotations = annotations.toList(), ranges = sensorRanges(active), @@ -145,6 +146,8 @@ internal data class MeasurementMetadata( val type: String, val date: String, val folder: String, + val sessionId: String, + val device: String, val notes: List, val annotations: List, val ranges: List, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt similarity index 89% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt index e6acd93..37d8f4f 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandler.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.Context -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.flatMap import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt similarity index 73% rename from sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt rename to sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt index 9b3b57e..25798dc 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageModule.kt @@ -1,7 +1,8 @@ -package com.tomasrepcik.sensorbox.sensorservices.di +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.Context import com.tomasrepcik.sensorbox.core.storage.DocumentStorage +import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock import com.tomasrepcik.sensorbox.core.storage.NativeDocumentStorage import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.core.time.SystemEpochClock @@ -18,7 +19,12 @@ import javax.inject.Singleton object StorageModule { @Provides @Singleton - fun provideDocumentStorage(@ApplicationContext context: Context): DocumentStorage = NativeDocumentStorage(context) + fun provideDocumentStorage(@ApplicationContext context: Context, syncLock: MeasurementSyncLock): DocumentStorage = + NativeDocumentStorage(context, syncLock) + + @Provides + @Singleton + fun provideMeasurementSyncLock(): MeasurementSyncLock = MeasurementSyncLock() @Provides @Singleton diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt deleted file mode 100644 index e18086d..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt +++ /dev/null @@ -1,185 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements - -import android.Manifest -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import com.google.android.gms.location.ActivityRecognition -import com.google.android.gms.location.ActivityRecognitionClient -import com.google.android.gms.location.ActivityRecognitionResult -import com.google.android.gms.location.ActivityTransition -import com.google.android.gms.location.ActivityTransitionRequest -import com.google.android.gms.location.ActivityTransitionResult -import com.google.android.gms.location.DetectedActivity -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import kotlinx.coroutines.tasks.await - -internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) - -internal data class ActivityTransitionSample( - val elapsedRealtimeNanos: Long, - val activityType: Int, - val transitionType: Int, -) - -internal interface ActivityRecognitionPlatform { - suspend fun start( - periodSeconds: Int, - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult - - suspend fun stop(): AppResult -} - -internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { - private var client: ActivityRecognitionClient? = null - private var updatesPendingIntent: PendingIntent? = null - private var transitionsPendingIntent: PendingIntent? = null - private var receiverRegistered = false - private var updateCallback: ((ActivityUpdate) -> Unit)? = null - private var transitionCallback: ((List) -> Unit)? = null - - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_UPDATE -> ActivityRecognitionResult.extractResult(intent)?.let { result -> - updateCallback?.invoke( - ActivityUpdate( - elapsedRealtimeMillis = result.elapsedRealtimeMillis, - confidences = ACTIVITIES.map(result::getActivityConfidence), - ), - ) - } - - ACTION_TRANSITION -> ActivityTransitionResult.extractResult(intent)?.let { result -> - transitionCallback?.invoke( - result.transitionEvents.map { transition -> - ActivityTransitionSample( - elapsedRealtimeNanos = transition.elapsedRealTimeNanos, - activityType = transition.activityType, - transitionType = transition.transitionType, - ) - }, - ) - } - } - } - } - - @SuppressLint("MissingPermission") - override suspend fun start( - periodSeconds: Int, - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult { - if (!hasPermission()) { - return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) - } - val prepared = prepareResources(onUpdate, onTransitions) - if (prepared.isFailure) return prepared - return suspendAppResult(AppErrorCode.RECORDING, "Start activity recognition") { - val activeClient = checkNotNull(client) { "Activity recognition client is missing" } - val updates = checkNotNull(updatesPendingIntent) { "Activity update request is missing" } - val transitions = checkNotNull(transitionsPendingIntent) { "Activity transition request is missing" } - activeClient.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, updates).await() - activeClient.requestActivityTransitionUpdates( - ActivityTransitionRequest(ACTIVITY_TRANSITIONS), - transitions, - ).await() - } - } - - private fun prepareResources( - onUpdate: (ActivityUpdate) -> Unit, - onTransitions: (List) -> Unit, - ): AppResult = appResult(AppErrorCode.RECORDING, "Initialize activity recognition resources") { - updateCallback = onUpdate - transitionCallback = onTransitions - val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } - ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) - receiverRegistered = true - client = ActivityRecognition.getClient(context) - updatesPendingIntent = PendingIntent.getBroadcast( - context, - UPDATES_REQUEST_CODE, - Intent(ACTION_UPDATE).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - transitionsPendingIntent = PendingIntent.getBroadcast( - context, - TRANSITIONS_REQUEST_CODE, - Intent(ACTION_TRANSITION).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } - - @SuppressLint("MissingPermission") - override suspend fun stop(): AppResult { - val results = mutableListOf>() - updatesPendingIntent?.let { pendingIntent -> - results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity updates") { - client?.removeActivityUpdates(pendingIntent)?.await() - } - } - transitionsPendingIntent?.let { pendingIntent -> - results += suspendAppResult(AppErrorCode.RECORDING, "Remove activity transitions") { - client?.removeActivityTransitionUpdates(pendingIntent)?.await() - } - } - results += appResult(AppErrorCode.RECORDING, "Release activity recognition resources") { - if (receiverRegistered) context.unregisterReceiver(receiver) - receiverRegistered = false - client = null - updatesPendingIntent = null - transitionsPendingIntent = null - updateCallback = null - transitionCallback = null - } - return results.combineAppResults(AppErrorCode.RECORDING, "Pause activity recognition") - } - - private fun hasPermission(): Boolean = Build.VERSION.SDK_INT < 29 || - ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == - PackageManager.PERMISSION_GRANTED - - private companion object { - const val ACTION_UPDATE = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_UPDATE" - const val ACTION_TRANSITION = "com.tomasrepcik.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" - const val UPDATES_REQUEST_CODE = 457 - const val TRANSITIONS_REQUEST_CODE = 1_654 - val ACTIVITIES = intArrayOf( - DetectedActivity.STILL, - DetectedActivity.ON_FOOT, - DetectedActivity.WALKING, - DetectedActivity.RUNNING, - DetectedActivity.IN_VEHICLE, - DetectedActivity.ON_BICYCLE, - DetectedActivity.UNKNOWN, - DetectedActivity.TILTING, - ) - val ACTIVITY_TRANSITIONS = ACTIVITIES.flatMap { activity -> - listOf( - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) - .build(), - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) - .build(), - ) - } - } -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt deleted file mode 100644 index 4e2b6b2..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController - -import android.content.Context -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.StorageHandler -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import kotlinx.coroutines.CoroutineScope -import javax.inject.Inject - -internal class ServiceControllerFactory @Inject constructor( - private val storage: StorageHandler, - private val diagnosticLogger: DiagnosticLogger, - private val clock: EpochClock, -) { - fun create(context: Context, request: RecordingRequest, scope: CoroutineScope) = ServiceController( - context = context, - request = request, - scope = scope, - storage = storage, - diagnosticLogger = diagnosticLogger, - clock = clock, - ) -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt deleted file mode 100644 index 908823e..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/RecordingService.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.services - -import android.app.Service -import android.content.Intent -import android.os.IBinder -import android.os.SystemClock -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.recording.RecordingEvent -import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceController -import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceControllerFactory -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopReason -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import javax.inject.Inject - -@AndroidEntryPoint -class RecordingService : Service() { - @Inject - lateinit var sessionStore: RecordingSessionStore - - @Inject - internal lateinit var controllerFactory: ServiceControllerFactory - - @Inject - lateinit var epochClock: EpochClock - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var controller: ServiceController? = null - private var activeRequest: RecordingRequest? = null - private var eventJob: Job? = null - private var isFinishing = false - private val hostResources by lazy { - RecordingHostResources( - service = this, - scope = serviceScope, - requestStop = ::requestStop, - playAlarm = { controller?.playAlarm() }, - ) - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP_RECORDING -> requestStop(RecordingStopReason.USER_REQUEST) - - ACTION_ANNOTATE -> controller?.annotate( - intent.getLongExtra(ANNOTATION_TIME, epochClock.nowMillis()), - intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), - ) - - else -> intent?.takeIf { activeRequest == null }?.let(::startRecordingHost) - } - return START_NOT_STICKY - } - - private fun startRecordingHost(intent: Intent) { - appResult(AppErrorCode.RECORDING, "Start recording foreground host") { - val request = RecordingRequest.from(intent) - require(request.sessionId.isNotBlank()) { "Recording session ID is missing" } - activeRequest = request - isFinishing = false - hostResources.start(request) - val serviceController = controllerFactory.create(this, request, serviceScope) - controller = serviceController - observeEngine(serviceController) - serviceScope.launch { - val result = serviceController.start() - if (result.isFailure) { - finishRecording( - RecordingSessionStopReason.SOURCE_FAILURE, - result, - ) - } - } - }.onFailure { error -> - serviceScope.launch { - finishRecording( - RecordingSessionStopReason.SOURCE_FAILURE, - AppResult.failure(error), - ) - } - } - } - - private fun observeEngine(serviceController: ServiceController) { - eventJob?.cancel() - eventJob = serviceScope.launch { - serviceController.events.collect { event -> - when (event) { - is RecordingEvent.RecordingStarted -> onRecordingStarted(event) - - is RecordingEvent.RecordingStopped -> finishRecording( - event.reason.toRecordingSessionReason(), - event.result, - ) - } - } - } - } - - private fun onRecordingStarted(event: RecordingEvent.RecordingStarted) { - val request = activeRequest ?: return - sessionStore.markRunning( - RecordingSessionState.Running( - sessionId = event.sessionId.value, - folderName = request.folderName, - startedAtElapsedRealtime = SystemClock.elapsedRealtime(), - sensorIds = request.sensorIds.toList(), - includesGps = request.includesGps, - ), - ) - hostResources.scheduleAlarms(request.alarmOffsetsSeconds) - } - - private fun requestStop(reason: RecordingStopReason) { - if (isFinishing) return - sessionStore.markStopping() - serviceScope.launch { - controller?.stop(reason) - } - } - - private suspend fun finishRecording(reason: RecordingSessionStopReason, engineResult: AppResult) { - if (isFinishing) return - isFinishing = true - val sessionId = activeRequest?.sessionId.orEmpty() - val hostResult = finishHost() - val result = listOf(engineResult, hostResult) - .combineAppResults(AppErrorCode.RECORDING, "Finish recording foreground host") - sessionStore.publishStopped(sessionId, reason, result) - eventJob?.cancel() - eventJob = null - } - - private fun finishHost(): AppResult { - val results = mutableListOf>() - results += hostResources.release() - activeRequest = null - controller = null - results += appResult(AppErrorCode.RECORDING, "Publish idle recording state") { - sessionStore.markIdle() - } - results += appResult(AppErrorCode.RECORDING, "Remove recording notification") { - hostResources.removeNotification() - } - results += appResult(AppErrorCode.RECORDING, "Stop recording service instance") { - stopSelf() - } - return results.combineAppResults(AppErrorCode.RECORDING, "Finish recording host resources") - } - - override fun onDestroy() { - val request = activeRequest - if (request != null && !isFinishing) { - isFinishing = true - val cleanup = runBlocking(Dispatchers.IO) { - controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) - } - val resources = hostResources.release() - sessionStore.markIdle() - sessionStore.publishStopped( - request.sessionId, - RecordingSessionStopReason.SERVICE_DESTROYED, - listOf(cleanup, resources).combineAppResults( - AppErrorCode.RECORDING, - "Destroy recording foreground host", - ), - ) - } - eventJob?.cancel() - serviceScope.cancel() - super.onDestroy() - } - - private fun RecordingStopReason.toRecordingSessionReason(): RecordingSessionStopReason = when (this) { - RecordingStopReason.USER_REQUEST -> RecordingSessionStopReason.USER_REQUEST - RecordingStopReason.DURATION_EXPIRED -> RecordingSessionStopReason.DURATION_EXPIRED - RecordingStopReason.LOW_BATTERY -> RecordingSessionStopReason.LOW_BATTERY - RecordingStopReason.SOURCE_FAILURE -> RecordingSessionStopReason.SOURCE_FAILURE - RecordingStopReason.PLATFORM_DESTROYED -> RecordingSessionStopReason.SERVICE_DESTROYED - } - - companion object { - const val SESSION_ID = "SESSION_ID" - const val FOLDER_NAME = "FOLDER_NAME" - const val INTERNAL_STORAGE = "INTERNAL_STORAGE" - const val ANDROID_SENSORS = "ANDROID_SENSORS" - const val ANDROID_SENSORS_SPEED = "ANDROID_SENSORS_SPEED" - const val GPS = "GPS_MEASURE" - const val STOP_ON_LOW_BATTERY = "STOP_ON_LOW_BATTERY" - const val USE_WAKE_LOCK = "USE_WAKE_LOCK" - const val GPS_INTERVAL_SECONDS = "GPS_INTERVAL_SECONDS" - const val GPS_DISTANCE_METERS = "GPS_DISTANCE_METERS" - const val DURATION_MILLIS = "DURATION_MILLIS" - const val NOTES = "NOTES" - const val ALARM_OFFSETS_SECONDS = "ALARM_OFFSETS_SECONDS" - const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" - const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" - const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" - const val ACTION_ANNOTATE = "com.tomasrepcik.sensorbox.action.ANNOTATE_MEASUREMENT" - const val ANNOTATION_TIME = "ANNOTATION_TIME" - const val ANNOTATION_TEXT = "ANNOTATION_TEXT" - const val ACTION_STOP_RECORDING = "com.tomasrepcik.sensorbox.action.STOP_RECORDING" - } -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt deleted file mode 100644 index fa3b3c7..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.session - -sealed interface RecordingSessionState { - data object Idle : RecordingSessionState - - data class Running( - val sessionId: String, - val folderName: String, - val startedAtElapsedRealtime: Long, - val sensorIds: List, - val includesGps: Boolean, - ) : RecordingSessionState - - data object Stopping : RecordingSessionState -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt deleted file mode 100644 index 752b71f..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/RecordingSessionStore.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.session - -import com.tomasrepcik.sensorbox.core.error.AppResult -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class RecordingSessionStore @Inject constructor() { - private val mutableState = MutableStateFlow(RecordingSessionState.Idle) - private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) - val state: StateFlow = mutableState.asStateFlow() - val events: SharedFlow = mutableEvents - - fun markRunning(state: RecordingSessionState.Running) { - mutableState.value = state - } - - fun markStopping() { - mutableState.value = RecordingSessionState.Stopping - } - - fun markIdle() { - mutableState.value = RecordingSessionState.Idle - } - - fun publishStopped(sessionId: String, reason: RecordingSessionStopReason, result: AppResult) { - mutableEvents.tryEmit(RecordingSessionStopped(sessionId, reason, result)) - } - - private companion object { - const val EVENT_BUFFER_SIZE = 16 - } -} - -enum class RecordingSessionStopReason { - USER_REQUEST, - DURATION_EXPIRED, - LOW_BATTERY, - SOURCE_FAILURE, - SERVICE_DESTROYED, -} - -data class RecordingSessionStopped( - val sessionId: String, - val reason: RecordingSessionStopReason, - val result: AppResult, -) diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt new file mode 100644 index 0000000..66297d9 --- /dev/null +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/session/RecordingHostSessionTest.kt @@ -0,0 +1,239 @@ +package com.tomasrepcik.sensorbox.recordinghost.session + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.recording.RecordingEvent +import com.tomasrepcik.sensorbox.recording.RecordingSessionId +import com.tomasrepcik.sensorbox.recording.RecordingStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingHostSessionTest { + @Test + fun `Given a recording request When execution starts Then running state and alarms are published`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + + // When + val result = fixture.session.start() + + // Then + assertTrue(result.isSuccess) + val running = fixture.store.state.value as RecordingSessionState.Running + assertEquals("session-123", running.sessionId) + assertEquals(55_000L, running.startedAtElapsedRealtime) + assertEquals(listOf(4, 8), fixture.environment.scheduledAlarms) + } + + @Test + fun `Given execution start failure When session starts Then host finishes and publishes failure`() = runTest { + // Given + val fixture = Fixture(backgroundScope).apply { + execution.startResult = AppResult.failure(AppError(AppErrorCode.RECORDING, "Start fixture execution")) + } + val stopped = async { fixture.store.events.first() } + runCurrent() + + // When + val result = fixture.session.start() + + // Then + assertTrue(result.isFailure) + assertEquals(RecordingSessionStopReason.SOURCE_FAILURE, stopped.await().reason) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(1, fixture.environment.removeNotificationCalls) + assertEquals(1, fixture.environment.stopServiceCalls) + } + + @Test + fun `Given execution stops twice When events arrive Then host cleanup runs once`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + runCurrent() + val stopped = async { fixture.store.events.first() } + runCurrent() + val event = RecordingEvent.RecordingStopped( + sessionId = RecordingSessionId("session-123"), + reason = RecordingStopReason.DURATION_EXPIRED, + result = AppResult.success(Unit), + ) + + // When + fixture.execution.emit(event) + runCurrent() + fixture.execution.emit(event) + runCurrent() + + // Then + assertEquals(RecordingSessionStopReason.DURATION_EXPIRED, stopped.await().reason) + assertTrue(fixture.store.state.value is RecordingSessionState.Idle) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(1, fixture.environment.stopServiceCalls) + } + + @Test + fun `Given a recording source fails When execution continues Then failures are stored in diagnostics`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + val sourceFailure = AppError(AppErrorCode.RECORDING, "Record SENSOR") + val stopFailure = AppError(AppErrorCode.RECORDING, "Stop SENSOR") + + // When + fixture.execution.emit( + RecordingEvent.SourceFailed( + sessionId = RecordingSessionId("session-123"), + sourceType = com.tomasrepcik.sensorbox.recording.RecordingSourceType.SENSOR, + failure = sourceFailure, + stopFailure = stopFailure, + ), + ) + runCurrent() + + // Then + assertEquals(listOf("Record SENSOR", "Stop SENSOR"), fixture.diagnostics.map(DiagnosticEvent::operation)) + assertTrue(fixture.store.state.value !is RecordingSessionState.Idle) + assertEquals(0, fixture.environment.releaseCalls) + } + + @Test + fun `Given execution reports a source failure during start When session starts Then failure is observed`() = + runTest { + // Given + val sourceFailure = AppError(AppErrorCode.RECORDING, "Record SENSOR") + val fixture = Fixture(backgroundScope).apply { + execution.eventDuringStart = RecordingEvent.SourceFailed( + sessionId = RecordingSessionId("session-123"), + sourceType = com.tomasrepcik.sensorbox.recording.RecordingSourceType.SENSOR, + failure = sourceFailure, + stopFailure = null, + ) + } + + // When + val result = fixture.session.start() + runCurrent() + + // Then + assertTrue(result.isSuccess) + assertEquals(listOf("Record SENSOR"), fixture.diagnostics.map(DiagnosticEvent::operation)) + } + + @Test + fun `Given an active host When service is destroyed Then execution and resources are released`() = runTest { + // Given + val fixture = Fixture(backgroundScope) + fixture.session.start() + runCurrent() + val stopped = async { fixture.store.events.first() } + runCurrent() + + // When + fixture.session.destroy() + + // Then + assertEquals(RecordingStopReason.PLATFORM_DESTROYED, fixture.execution.stopReasons.single()) + assertEquals(RecordingSessionStopReason.SERVICE_DESTROYED, stopped.await().reason) + assertEquals(1, fixture.environment.releaseCalls) + assertEquals(0, fixture.environment.stopServiceCalls) + } + + private class Fixture(scope: kotlinx.coroutines.CoroutineScope) { + val execution = FakeRecordingSessionExecution() + val environment = FakeRecordingHostEnvironment() + val store = RecordingSessionStore() + val diagnostics = mutableListOf() + val session = RecordingHostSession( + request = request(), + execution = execution, + environment = environment, + sessionStore = store, + diagnosticLogger = DiagnosticLogger(diagnostics::add), + scope = scope, + elapsedRealtimeMillis = { 55_000L }, + ) + + private fun request() = RecordingRequest( + sessionId = "session-123", + folderName = "fixture", + useInternalStorage = true, + sensorIds = setOf(1), + sensorSamplingPeriod = 0, + includesGps = false, + stopOnLowBattery = false, + useWakeLock = false, + gpsIntervalSeconds = 10, + gpsMinDistanceMeters = 20, + alarmOffsetsSeconds = listOf(4, 8), + ) + } +} + +private class FakeRecordingSessionExecution : RecordingSessionExecution { + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 8) + override val events: SharedFlow = mutableEvents + var startResult: AppResult = AppResult.success(Unit) + var eventDuringStart: RecordingEvent? = null + val stopReasons = mutableListOf() + + override suspend fun start(): AppResult { + eventDuringStart?.let(mutableEvents::tryEmit) + return startResult + } + + override suspend fun stop(reason: RecordingStopReason): AppResult { + stopReasons += reason + return AppResult.success(Unit) + } + + override fun annotate(timestampMillis: Long, text: String): AppResult = AppResult.success(Unit) + + override fun playAlarm(): AppResult = AppResult.success(Unit) + + fun emit(event: RecordingEvent) { + mutableEvents.tryEmit(event) + } +} + +private class FakeRecordingHostEnvironment : RecordingHostEnvironment { + var scheduledAlarms: List = emptyList() + var releaseCalls = 0 + var removeNotificationCalls = 0 + var stopServiceCalls = 0 + + override fun start(request: RecordingRequest) = Unit + + override fun scheduleAlarms(offsetsSeconds: List) { + scheduledAlarms = offsetsSeconds + } + + override fun release(): AppResult { + releaseCalls += 1 + return AppResult.success(Unit) + } + + override fun removeNotification() { + removeNotificationCalls += 1 + } + + override fun stopService() { + stopServiceCalls += 1 + } +} diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt similarity index 76% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt index 4cc244d..1cb4472 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/RecordingSourceStartTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/RecordingSourceStartTest.kt @@ -1,13 +1,15 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources import android.hardware.Sensor -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GPSHandler +import com.tomasrepcik.sensorbox.recordinghost.sources.gps.GpsRecording +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorRecording +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt new file mode 100644 index 0000000..5ea2e56 --- /dev/null +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionPlatformTest.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.recordinghost.sources.activity + +import com.google.android.gms.location.DetectedActivity +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ActivityRecognitionPlatformTest { + @Test + fun `Given activity updates When confidence types are requested Then every CSV activity is included`() { + // Given + val expected = intArrayOf( + DetectedActivity.STILL, + DetectedActivity.ON_FOOT, + DetectedActivity.WALKING, + DetectedActivity.RUNNING, + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_BICYCLE, + DetectedActivity.UNKNOWN, + DetectedActivity.TILTING, + ) + + // When + val actual = ACTIVITY_CONFIDENCE_TYPES + + // Then + assertArrayEquals(expected, actual) + } + + @Test + fun `Given transition updates When activity types are requested Then only supported types are included`() { + // Given + val expected = intArrayOf( + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_FOOT, + DetectedActivity.RUNNING, + DetectedActivity.WALKING, + DetectedActivity.ON_BICYCLE, + DetectedActivity.STILL, + ) + + // When + val actual = ACTIVITY_TRANSITION_TYPES + + // Then + assertArrayEquals(expected, actual) + } +} diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt similarity index 93% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt index 6e7424a..c80f6c3 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionRecordingTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/activity/ActivityRecognitionRecordingTest.kt @@ -1,9 +1,9 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.sources.activity -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.recordinghost.storage.MeasurementStorage import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt similarity index 90% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt index 224ae69..112eb03 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorHolderErrorTest.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import android.hardware.Sensor -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.DiagnosticEvent -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.DiagnosticEvent +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt similarity index 93% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt index b4a71d2..a9127d0 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/sources/sensor/SensorSpecTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.sensorservices.types +package com.tomasrepcik.sensorbox.recordinghost.sources.sensor import org.junit.Assert.assertEquals import org.junit.Test diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt similarity index 91% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt index dabd4b1..6e70d54 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/MeasurementMetadataSerializationTest.kt @@ -1,7 +1,6 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +package com.tomasrepcik.sensorbox.recordinghost.storage -import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats -import kotlinx.serialization.encodeToString +import com.tomasrepcik.sensorbox.recordinghost.sources.sensor.SensorFileStats import kotlinx.serialization.json.Json import kotlinx.serialization.json.boolean import kotlinx.serialization.json.float @@ -27,6 +26,8 @@ class MeasurementMetadataSerializationTest { "type", "date", "folder", + "sessionId", + "device", "notes", "annotations", "ranges", @@ -45,6 +46,8 @@ class MeasurementMetadataSerializationTest { ), json.keys, ) + assertEquals("shared-session", json.getValue("sessionId").jsonPrimitive.content) + assertEquals("watch", json.getValue("device").jsonPrimitive.content) assertEquals(100L, json.getValue("millis").jsonPrimitive.long) assertEquals(200L, json.getValue("nanos").jsonPrimitive.long) assertEquals(60_100L, json.getValue("endedAtMillis").jsonPrimitive.long) @@ -73,6 +76,8 @@ class MeasurementMetadataSerializationTest { type = "RECORDING", date = "23. 08. 2026 12:00:00", folder = "fixture", + sessionId = "shared-session", + device = "watch", notes = listOf("note"), annotations = listOf(MeasurementAnnotation(timestamp = 300L, annotation = "mark")), ranges = listOf(SensorRange(sensor = "Accelerometer", type = 1, range = 9.81f)), diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt similarity index 79% rename from sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt rename to sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt index 7b810b6..4187065 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/StorageHandlerTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/recordinghost/storage/StorageHandlerTest.kt @@ -1,15 +1,14 @@ -package com.tomasrepcik.sensorbox.sensorservices.handlers +package com.tomasrepcik.sensorbox.recordinghost.storage import android.content.ContextWrapper -import android.content.Intent -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage +import com.tomasrepcik.sensorbox.core.storage.MeasurementImport import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayOutputStream -import java.io.InputStream import java.io.OutputStream class StorageHandlerTest { @@ -33,11 +32,13 @@ class StorageHandlerTest { } private class FakeDocumentStorage : DocumentStorage { + override fun beginMeasurementImport(measurementName: String): AppResult = error("Not used") + val output = ByteArrayOutputStream() var createdMeasurement: String? = null var openedFile: String? = null - override fun persistRootAccess(intent: Intent): AppResult = AppResult.success(Unit) + override fun persistRootAccess(uri: String, grantFlags: Int): AppResult = AppResult.success(Unit) override fun hasConfiguredDirectory(): AppResult = AppResult.success(true) @@ -52,19 +53,11 @@ class StorageHandlerTest { measurementName: String, mimeType: String, fileName: String, - replaceExisting: Boolean, ): AppResult { openedFile = fileName return AppResult.success(output) } 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/shared-resources/branding/drawable/ic_sensorbox_logo.xml b/shared-resources/branding/drawable/ic_sensorbox_logo.xml index 8d7fff9..9f2b212 100644 --- a/shared-resources/branding/drawable/ic_sensorbox_logo.xml +++ b/shared-resources/branding/drawable/ic_sensorbox_logo.xml @@ -1,4 +1,5 @@ diff --git a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml index 3e765ce..7248a34 100644 --- a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml +++ b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml index 3e765ce..7248a34 100644 --- a/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/shared-resources/branding/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + diff --git a/tools/emulator/run_phone_paired_recording_test.sh b/tools/emulator/run_phone_paired_recording_test.sh new file mode 100755 index 0000000..666bd34 --- /dev/null +++ b/tools/emulator/run_phone_paired_recording_test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +adb_bin="${ADB_BIN:-$(command -v adb)}" +phone_test="com.tomasrepcik.sensorbox.pairedrecording.PhonePairedRecordingEmulatorTest" +runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" +result_file="$(mktemp -t sensorbox-paired-recording.XXXXXX)" + +cleanup() { + rm -f "$result_file" +} +trap cleanup EXIT + +detect_emulator() { + local expected_role="$1" + local serial characteristics + local matches=() + + while read -r serial; do + [[ "$serial" == emulator-* ]] || continue + characteristics="$($adb_bin -s "$serial" shell getprop ro.build.characteristics 1 && $2 == "device" { print $1 }') + + if [[ "${#matches[@]}" -ne 1 ]]; then + local serial_variable="PHONE_SERIAL" + [[ "$expected_role" == "wear" ]] && serial_variable="WEAR_SERIAL" + echo "Expected exactly one $expected_role emulator; found: ${matches[*]:-none}. Set $serial_variable." >&2 + exit 2 + fi + printf '%s' "${matches[0]}" +} + +phone_serial="${PHONE_SERIAL:-$(detect_emulator phone)}" +wear_serial="${WEAR_SERIAL:-$(detect_emulator wear)}" + +connect_emulators() { + "$adb_bin" -s "$phone_serial" forward tcp:5602 tcp:5601 >/dev/null + "$adb_bin" -s "$wear_serial" reverse tcp:5601 tcp:5602 >/dev/null + "$adb_bin" -s "$wear_serial" shell am broadcast \ + -a com.google.android.gms.wearable.EMULATOR \ + --es operation refresh-emulator-connection >/dev/null +} + +require_paired_emulators() { + local pairing_status + pairing_status="$("$adb_bin" -s "$wear_serial" shell am broadcast \ + -a com.google.android.gms.wearable.EMULATOR \ + --es operation get-pairing-status)" + if [[ "$pairing_status" == *"Peer:[null"* ]]; then + echo "Wear emulator has no paired phone. Pair both devices with Android Studio's Pairing Assistant first." >&2 + exit 2 + fi +} + +cd "$repo_root" +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :wear:assembleDebug + +"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/debug/app-debug.apk +"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +"$adb_bin" -s "$wear_serial" install -r wear/build/outputs/apk/debug/wear-debug.apk + +"$adb_bin" -s "$phone_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null +"$adb_bin" -s "$wear_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null +connect_emulators +require_paired_emulators + +echo "Running phone-driven paired recording on $phone_serial with $wear_serial" +"$adb_bin" -s "$phone_serial" shell am instrument -w -r \ + -e class "$phone_test" "$runner" >"$result_file" +cat "$result_file" +grep -q "OK (1 test)" "$result_file" diff --git a/tools/emulator/run_wear_sync_test.sh b/tools/emulator/run_wear_sync_test.sh index b2aaa5f..193a49b 100755 --- a/tools/emulator/run_wear_sync_test.sh +++ b/tools/emulator/run_wear_sync_test.sh @@ -3,10 +3,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" adb_bin="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb" -phone_test="com.tomasrepcik.sensorbox.emulator.WearToPhoneSyncReceiverEmulatorTest" -wear_test="com.tomasrepcik.sensorbox.emulator.WearToPhoneSyncSenderEmulatorTest" +phone_test="com.tomasrepcik.sensorbox.measurements.sync.WearToPhoneSyncReceiverEmulatorTest" +wear_test="com.tomasrepcik.sensorbox.sync.WearToPhoneSyncSenderEmulatorTest" runner="com.tomasrepcik.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" result_directory="$(mktemp -d -t sensorbox-wear-sync.XXXXXX)" +# Select a dedicated recording folder in the phone app before running this script. scenarios=( single_csv mixed_formats @@ -85,15 +86,16 @@ run_scenario() { local wear_result="$result_directory/$scenario-wear.txt" echo "Running paired sync scenario: $scenario" - "$adb_bin" -s "$phone_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null - "$adb_bin" -s "$wear_serial" shell pm clear com.tomasrepcik.sensorbox >/dev/null - "$adb_bin" -s "$phone_serial" shell am instrument -w -r \ - -e syncScenario "$scenario" -e class "$phone_test" "$runner" >"$phone_result" & + # Keep the phone's selected archive permission, just as normal app sync does. + "$adb_bin" -s "$phone_serial" shell am force-stop com.tomasrepcik.sensorbox >/dev/null + "$adb_bin" -s "$wear_serial" shell am force-stop com.tomasrepcik.sensorbox >/dev/null + "$adb_bin" -s "$wear_serial" shell am instrument -w -r \ + -e syncScenario "$scenario" -e class "$wear_test" "$runner" >"$wear_result" & phone_test_pid=$! sleep 2 - "$adb_bin" -s "$wear_serial" shell am instrument -w -r \ - -e syncScenario "$scenario" -e class "$wear_test" "$runner" >"$wear_result" + "$adb_bin" -s "$phone_serial" shell am instrument -w -r \ + -e syncScenario "$scenario" -e class "$phone_test" "$runner" >"$phone_result" wait "$phone_test_pid" phone_test_pid="" diff --git a/wear/lint.xml b/wear/lint.xml new file mode 100644 index 0000000..93f6fc5 --- /dev/null +++ b/wear/lint.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/design/WearSystemThemeTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/design/WearSystemThemeTest.kt new file mode 100644 index 0000000..2db93b9 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/design/WearSystemThemeTest.kt @@ -0,0 +1,46 @@ +package com.tomasrepcik.sensorbox.design + +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.wear.compose.material3.LocalContentColor +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class WearSystemThemeTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenAutomaticThemeWhenSystemChangesThenBackgroundAndTextFollowIt() { + // Given + val night = mutableStateOf(false) + var textColor = Color.Transparent + composeRule.setContent { + val configuration = Configuration(LocalConfiguration.current).apply { + uiMode = if (night.value) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + CompositionLocalProvider(LocalConfiguration provides configuration) { + WearSensorBoxTheme(dynamicColor = false) { textColor = LocalContentColor.current } + } + } + composeRule.waitForIdle() + assertEquals(Color.White, composeRule.onRoot().captureToImage().toPixelMap()[50, 50]) + assertEquals(Color.Black, textColor) + + // When + composeRule.runOnIdle { night.value = true } + composeRule.waitForIdle() + + // Then + assertEquals(Color.Black, composeRule.onRoot().captureToImage().toPixelMap()[50, 50]) + assertEquals(Color.White, textColor) + } +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt similarity index 89% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt index 4524715..7e70354 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreenTest.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt deleted file mode 100644 index 4de612d..0000000 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.tomasrepcik.sensorbox.emulator - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.gms.tasks.Tasks -import com.google.android.gms.wearable.CapabilityClient -import com.google.android.gms.wearable.Wearable -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.GooglePlayWearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.files.GooglePlayWearFileTransferClient -import com.tomasrepcik.sensorbox.wearoslib.files.WearSyncEmulatorFixture -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File - -@RunWith(AndroidJUnit4::class) -class WearToPhoneSyncSenderEmulatorTest { - private val context: Context = ApplicationProvider.getApplicationContext() - private val scenario = WearSyncEmulatorFixture.scenario( - requireNotNull( - InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT), - ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" }, - ) - - @Before - fun createScenarioFixture() { - val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY) - root.deleteRecursively() - scenario.emptyMeasurementNames.forEach { File(root, it).mkdirs() } - scenario.files.forEach { fixture -> - File(root, fixture.measurementName).also(File::mkdirs) - .resolve(fixture.fileName) - .writeBytes(fixture.content) - } - } - - @Test - fun givenPairedEmulatorsWhenWearSyncsThenEverySupportedFileIsTransferred() = runBlocking { - val sync = SyncWatchMeasurementsUseCase( - context = context, - connectionRepository = GooglePlayWearConnectionRepository(context), - transferClient = GooglePlayWearFileTransferClient(context), - ) - - assertEquals(scenario.transferredFiles.size, syncWhenPhoneBecomesReachable(sync)) - } - - private suspend fun syncWhenPhoneBecomesReachable(sync: SyncWatchMeasurementsUseCase): Int { - var lastFailure: AppError? = null - repeat(MAX_ATTEMPTS) { - when (val result = sync()) { - is AppResult.Success -> return result.value - is AppResult.Failure -> lastFailure = result.error - } - delay(POLL_INTERVAL_MILLIS) - } - val message = "Phone emulator did not become reachable: $lastFailure; ${connectionDiagnostics()}" - throw AssertionError(message, lastFailure?.cause) - } - - private suspend fun connectionDiagnostics(): String = runCatching { - val nodes = Tasks.await(Wearable.getNodeClient(context).connectedNodes).joinToString { it.displayName } - val capabilities = Tasks.await( - Wearable.getCapabilityClient(context).getAllCapabilities(CapabilityClient.FILTER_ALL), - ) - .mapValues { (_, info) -> info.nodes.map { it.displayName } } - "connectedNodes=[$nodes], capabilities=$capabilities" - }.getOrElse { "diagnostics failed: $it" } - - private companion object { - const val POLL_INTERVAL_MILLIS = 1_000L - const val MAX_ATTEMPTS = 60 - } -} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/home/WearDashboardScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/home/WearDashboardScreenTest.kt new file mode 100644 index 0000000..2de1afe --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/home/WearDashboardScreenTest.kt @@ -0,0 +1,95 @@ +package com.tomasrepcik.sensorbox.home + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Density +import androidx.test.core.app.ApplicationProvider +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.File +import kotlin.math.hypot + +class WearDashboardScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenPhoneRecordingWhenDisplayedThenActualSourcesAndAccessibleStopAreShown() { + // Given + val state = WearDashboardState( + route = WearRoute.ACTIVE, + selectedSensorIds = setOf(1, 2, 3), + includesGps = true, + activeSession = RecordingSessionState.Running("phone", "walk", 0L, listOf(4), false, 60_000L, 3), + ) + var received: WearDashboardIntent? = null + composeRule.setContent { + WearSensorBoxTheme { WearDashboardScreen(state) { received = it } } + } + + // When + val stop = composeRule.onNodeWithText("Stop").assertIsDisplayed() + val bounds = stop.fetchSemanticsNode().boundsInRoot + val screen = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + saveScreen("watch-recording.png") + stop.performClick() + + // Then + composeRule.onNodeWithText("1 source").assertIsDisplayed() + composeRule.onNodeWithText("GPS off").assertIsDisplayed() + assertEquals(WearDashboardIntent.StopRecording, received) + val radius = minOf(screen.width, screen.height) / 2 + listOf(bounds.topLeft, bounds.topRight, bounds.bottomLeft, bounds.bottomRight).forEach { corner -> + assertTrue("Stop button crosses the round screen edge", hypot( + corner.x - screen.center.x, + corner.y - screen.center.y, + ) < radius) + } + } + @Test + fun givenLargeTextWhenRecordingThenStopRemainsInsideTheRoundScreen() { + // Given + val state = WearDashboardState( + route = WearRoute.ACTIVE, + activeSession = RecordingSessionState.Running("phone", "walk", 0L, listOf(4), false), + ) + composeRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.3f)) { + WearSensorBoxTheme { WearDashboardScreen(state) {} } + } + } + + // When + val stop = composeRule.onNodeWithText("Stop").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val screen = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + saveScreen("watch-recording-large-text.png") + + // Then + val radius = minOf(screen.width, screen.height) / 2 + listOf(stop.topLeft, stop.topRight, stop.bottomLeft, stop.bottomRight).forEach { corner -> + assertTrue(hypot(corner.x - screen.center.x, corner.y - screen.center.y) < radius) + } + } + + private fun saveScreen(name: String) { + val context: Context = ApplicationProvider.getApplicationContext() + val bitmap = composeRule.onRoot().captureToImage().asAndroidBitmap() + File(context.getExternalFilesDir(null), name).outputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + } + } + +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt similarity index 68% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt index 85216d1..76e28e2 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuRobot.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuRobot.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme class WearMenuRobot( private val rule: ComposeContentTestRule, @@ -21,15 +21,8 @@ class WearMenuRobot( rule.onNodeWithContentDescription("Record").performClick() } - fun whenSyncIsTapped() = apply { - rule.onNodeWithContentDescription("Sync recordings").performClick() - } - fun thenRecordIsVisible() = apply { rule.onNodeWithContentDescription("Record").assertIsDisplayed() } - fun thenSyncIsVisible() = apply { - rule.onNodeWithContentDescription("Sync recordings").assertIsDisplayed() - } } diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt similarity index 59% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt index 7a5bc9a..7680c5c 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreenTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/menu/WearMenuScreenTest.kt @@ -1,5 +1,6 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.junit4.v2.createComposeRule import org.junit.Assert.assertEquals import org.junit.Rule @@ -23,15 +24,12 @@ class WearMenuScreenTest { } @Test - fun givenMenuWhenSyncIsTappedThenSyncDestinationIsSelected() { - var selectedDestination: WearMenuDestination? = null - WearMenuRobot(composeRule) - .givenMenu { selectedDestination = it } - .thenSyncIsVisible() - .whenSyncIsTapped() + fun givenMenuWhenDisplayedThenSyncIsNotOffered() { + // Given + WearMenuRobot(composeRule).givenMenu {} - composeRule.runOnIdle { - assertEquals(WearMenuDestination.SYNC, selectedDestination) - } + // Then + composeRule.onNodeWithText("Sync recordings").assertDoesNotExist() + composeRule.onNodeWithText("Sync to phone").assertDoesNotExist() } } diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchButtonTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchButtonTest.kt new file mode 100644 index 0000000..bade428 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchButtonTest.kt @@ -0,0 +1,36 @@ +package com.tomasrepcik.sensorbox.phonelaunch + +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.onRoot +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.math.hypot + +class PhoneLaunchButtonTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenRoundWatchWhenPhoneRequestIsSentThenButtonLabelFitsInsideDisplay() { + // Given + composeRule.setContent { + WearSensorBoxTheme { + PhoneLaunchScreen(PhoneLaunchState(true, PhoneLaunchStatus.SENT)) {} + } + } + + // When + val label = composeRule.onNodeWithText("Open phone app").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val screen = composeRule.onRoot().fetchSemanticsNode().boundsInRoot + + // Then + val radius = minOf(screen.width, screen.height) / 2 + listOf(label.topLeft, label.topRight, label.bottomLeft, label.bottomRight).forEach { corner -> + assertTrue(hypot(corner.x - screen.center.x, corner.y - screen.center.y) < radius) + } + } +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt similarity index 90% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt index 0925a67..f6c0288 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearRecordingEmulatorFixture.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.Manifest import android.content.Context @@ -9,8 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences import com.tomasrepcik.sensorbox.core.time.SystemEpochClock -import com.tomasrepcik.sensorbox.domain.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -168,16 +167,6 @@ internal class WearRecordingEmulatorFixture(private val context: Context) { ) } - private fun rows(scenario: WearRecordingScenario, sensor: WearRecordedSensor): List { - val output = File(measurementDirectory(scenario.name), sensor.fileName) - repeat(FILE_WAIT_ATTEMPTS) { - val rows = output.takeIf(File::isFile)?.readLines().orEmpty() - if (rows.isNotEmpty()) return rows - Thread.sleep(FILE_WAIT_INTERVAL_MILLIS) - } - return output.takeIf(File::isFile)?.readLines().orEmpty() - } - private fun awaitMetadata(directory: File): JSONObject { val output = File(directory, METADATA_FILE) repeat(FILE_WAIT_ATTEMPTS) { @@ -207,12 +196,6 @@ internal class WearRecordingEmulatorFixture(private val context: Context) { fixture.setBatteryLevel(level) } - fun assertOnlyHeaders(): ActiveWearRecording = apply { - scenario.sensors.forEach { sensor -> - assertEquals(listOf(sensor.header), fixture.rows(scenario, sensor)) - } - } - fun stop(): WearRecordingOutput { fixture.controller.stop().getOrThrow() return fixture.complete(scenario, scheduledStartMillis) @@ -234,13 +217,6 @@ internal class WearRecordingEmulatorFixture(private val context: Context) { if (scenario.includesGps) assertGpsCsv(files.getValue(GPS_FILE)) } - fun assertCancelledBeforeStart(): WearRecordingOutput = apply { - assertSessionMetadata() - assertExactFiles() - scenario.sensors.forEach { sensor -> - assertEquals(listOf(sensor.header), files.getValue(sensor.fileName).readLines()) - } - } private fun assertSessionMetadata() { assertEquals(scenario.name, metadata.getString("folder")) @@ -299,7 +275,7 @@ internal class WearRecordingEmulatorFixture(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 MINIMUM_ROWS = 2 diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt similarity index 98% rename from wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt rename to wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt index 27bce9c..f1dfb23 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearSensorRecordingEmulatorTest.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/WearSensorRecordingEmulatorTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.emulator +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.hardware.Sensor diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreenTest.kt new file mode 100644 index 0000000..c42e870 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreenTest.kt @@ -0,0 +1,38 @@ +package com.tomasrepcik.sensorbox.recording.live + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.home.WearDashboardState +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor +import org.junit.Rule +import org.junit.Test + +class WearLiveScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenNoSamplesWhenPreviewStartsAndSensorsChangeThenChartDoesNotCrash() { + // Given + val sensor = WatchSensorDescriptor(1, "Accelerometer", "Test", 1, "sensor", 10f, 1f, 1f, 0, 0, 0, false) + val other = sensor.copy(type = 4, name = "Gyroscope") + val state = mutableStateOf(WearDashboardState(sensors = listOf(sensor, other), liveSensorType = 1)) + composeRule.setContent { WearSensorBoxTheme { WearLiveScreen(state.value) {} } } + composeRule.waitForIdle() + + // When + composeRule.runOnIdle { state.value = state.value.copy(liveSamples = listOf(listOf(1f, 2f, 3f), listOf(2f, 3f, 4f))) } + composeRule.waitForIdle() + composeRule.onNodeWithText("X:", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("Y:", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("Z:", substring = true).assertIsDisplayed() + composeRule.runOnIdle { state.value = state.value.copy(liveSensorType = 4, liveSamples = emptyList()) } + composeRule.waitForIdle() + + // Then + composeRule.onNodeWithText("Gyroscope").assertIsDisplayed() + } +} diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt new file mode 100644 index 0000000..229ce95 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/sync/WearToPhoneSyncSenderEmulatorTest.kt @@ -0,0 +1,59 @@ +package com.tomasrepcik.sensorbox.sync + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.tomasrepcik.sensorbox.wearoslib.sync.WearSyncEmulatorFixture +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.time.Duration.Companion.milliseconds + +@RunWith(AndroidJUnit4::class) +class WearToPhoneSyncSenderEmulatorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val scenario = WearSyncEmulatorFixture.scenario( + requireNotNull( + InstrumentationRegistry.getArguments().getString(WearSyncEmulatorFixture.SCENARIO_ARGUMENT), + ) { "Missing ${WearSyncEmulatorFixture.SCENARIO_ARGUMENT} instrumentation argument" }, + ) + + @Before + fun createScenarioFixture() { + val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY) + (scenario.files.map { it.measurementName } + scenario.emptyMeasurementNames).distinct().forEach { + File(root, it).deleteRecursively() + } + scenario.emptyMeasurementNames.forEach { File(root, it).mkdirs() } + scenario.files.forEach { fixture -> + File(root, fixture.measurementName).also(File::mkdirs) + .resolve(fixture.fileName) + .writeBytes(fixture.content) + } + } + + @Test + fun givenPairedEmulatorsWhenWearSyncsThenEverySupportedFileIsTransferred() = runBlocking { + // Given + android.util.Log.i("SensorBoxEmulatorTest", "READY_FOR_PHONE_SYNC") + val root = File(context.filesDir, WearSyncEmulatorFixture.APP_DIRECTORY) + + // When + val cleaned = kotlinx.coroutines.withTimeoutOrNull(60_000L.milliseconds) { + while (scenario.transferredFiles.any { File(root, "${it.measurementName}/${it.fileName}").exists() }) { + delay(100L.milliseconds) + } + true + } + + // Then + org.junit.Assert.assertTrue("Watch did not receive a committed-folder acknowledgement", cleaned == true) + scenario.files.filterNot { it.shouldTransfer }.forEach { + org.junit.Assert.assertTrue(File(root, "${it.measurementName}/${it.fileName}").exists()) + } + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 7846938..276ee0f 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -13,13 +13,11 @@ - - - - @@ -44,11 +42,11 @@ diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt similarity index 85% rename from wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt index 5eff9f5..b55ee4a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MainActivity.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.activities +package com.tomasrepcik.sensorbox.bootstrap import android.content.Intent import android.os.Bundle @@ -16,15 +16,15 @@ import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.remote.interactions.RemoteActivityHelper import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardEffect -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardIntent -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardScreen -import com.tomasrepcik.sensorbox.presentation.dashboard.WearDashboardViewModel -import com.tomasrepcik.sensorbox.presentation.dashboard.WearRoute -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.home.WearDashboardEffect +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardScreen +import com.tomasrepcik.sensorbox.home.WearDashboardViewModel +import com.tomasrepcik.sensorbox.home.WearRoute +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -54,7 +54,6 @@ class MainActivity : ComponentActivity() { updateDisplayPolicy(state.preferences.display.keepWearDisplayOn) } WearSensorBoxTheme( - themeMode = state.preferences.display.themeMode, dynamicColor = state.preferences.display.dynamicColors, ) { WearDashboardScreen( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt index 2f9b424..99f5967 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/MoveToMain.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.activities +package com.tomasrepcik.sensorbox.bootstrap import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,9 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchScreen -import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchViewModel -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.phonelaunch.PhoneLaunchScreen +import com.tomasrepcik.sensorbox.phonelaunch.PhoneLaunchViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt similarity index 71% rename from wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt index a456bff..ac7e73a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/WearSensorBoxApp.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/bootstrap/WearSensorBoxApp.kt @@ -1,8 +1,8 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.bootstrap import android.app.Application -import com.tomasrepcik.sensorbox.communication.WearRecordingSessionObserver -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingSessionObserver import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt deleted file mode 100644 index 8703f6f..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/MsgListener.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tomasrepcik.sensorbox.communication - -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.WearableListenerService -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: WearMessageDispatcher - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - override fun onMessageReceived(messageEvent: MessageEvent) { - serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) } - } - - override fun onDestroy() { - serviceScope.cancel() - super.onDestroy() - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt deleted file mode 100644 index 152ffe8..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/PhoneRecordingResultInbox.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tomasrepcik.sensorbox.communication - -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PhoneRecordingResultInbox @Inject constructor() { - private val results = ConcurrentHashMap() - private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) - - fun publish(result: WearCommand.RecordingResult) { - results[result.key()] = result - updates.tryEmit(result) - } - - fun clear(sessionId: String, operation: WearRecordingOperation) { - results.remove(Key(sessionId, operation)) - } - - suspend fun await(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult { - val key = Key(sessionId, operation) - results[key]?.let { return it } - return updates.first { result -> result.key() == key } - } - - private fun WearCommand.RecordingResult.key() = Key(sessionId, operation) - - private data class Key(val sessionId: String, val operation: WearRecordingOperation) - - private companion object { - const val BUFFER_SIZE = 16 - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt similarity index 73% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt index 5168f6e..d541c02 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearButtons.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -7,7 +7,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope -import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.EdgeButton import androidx.wear.compose.material3.EdgeButtonSize import androidx.wear.compose.material3.OutlinedButton @@ -16,23 +15,6 @@ import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.lazy.TransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight -@Composable -internal fun TransformingLazyColumnItemScope.WearPrimaryButton( - label: String, - transformation: TransformationSpec, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), - transformation = SurfaceTransformation(transformation), - ) { - Text(label) - } -} - @Composable internal fun TransformingLazyColumnItemScope.WearSecondaryButton( label: String, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt similarity index 98% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt index ef18140..bbbf471 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearScreenComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt similarity index 98% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt index a755238..909cfe8 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearSelectionComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation +package com.tomasrepcik.sensorbox.design import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.fillMaxWidth diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt similarity index 79% rename from wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt index fdc60c0..bfd59dd 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/ui/theme/WearTheme.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/design/WearTheme.kt @@ -1,10 +1,16 @@ -package com.tomasrepcik.sensorbox.ui.theme +package com.tomasrepcik.sensorbox.design +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.LocalContentColor import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.dynamicColorScheme import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode @@ -62,5 +68,9 @@ fun WearSensorBoxTheme( } val fallback = if (darkTheme) DarkWearColors else LightWearColors val colors = if (dynamicColor && darkTheme) dynamicColorScheme(LocalContext.current) ?: fallback else fallback - MaterialTheme(colorScheme = colors, content = content) + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider(LocalContentColor provides colors.onBackground) { + Box(Modifier.fillMaxSize().background(colors.background)) { content() } + } + } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt similarity index 76% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt index 517a3a1..c8f2483 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/DiagnosticsModule.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.diagnostics import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.CompositeDiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.DiagnosticMetadata -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore -import com.tomasrepcik.sensorbox.core.error.FileDiagnostics -import com.tomasrepcik.sensorbox.core.error.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.CompositeDiagnosticLogger +import com.tomasrepcik.sensorbox.core.diagnostics.DiagnosticMetadata +import com.tomasrepcik.sensorbox.core.diagnostics.FileDiagnostics +import com.tomasrepcik.sensorbox.core.diagnostics.LogcatDiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.DiagnosticsStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt index 27472ce..0c5c8ba 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/diagnostics/WearAppErrorScreen.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.diagnostics import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,7 +15,7 @@ import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode @Composable internal fun WearAppErrorScreen(errorCode: AppErrorCode, onDismiss: () -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt deleted file mode 100644 index b53102a..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.sync - -import android.content.Context -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.suspendAppResult -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileMetadata -import com.tomasrepcik.sensorbox.wearoslib.files.WearFileTransferClient -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject - -fun interface SyncWatchMeasurementsUseCase { - suspend operator fun invoke(): AppResult -} - -class DefaultSyncWatchMeasurementsUseCase @Inject constructor( - @ApplicationContext private val context: Context, - private val connectionRepository: WearConnectionRepository, - private val transferClient: WearFileTransferClient, -) : SyncWatchMeasurementsUseCase { - override suspend fun invoke(): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") { - connectionRepository.findNode(PHONE_APP_CAPABILITY) - }.suspendFlatMap { node -> - if (node == null) { - return@suspendFlatMap AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find connected phone")) - } - appResult(AppErrorCode.STORAGE, "List watch measurements", ::measurementFiles).suspendFlatMap { files -> - var transferResult: AppResult = AppResult.success(Unit) - for (file in files) { - if (transferResult.isFailure) break - transferResult = sendFile(node.id, file) - } - transferResult.map { files.size } - } - }.withAppError(AppErrorCode.CONNECTIVITY, "Sync watch measurements") - - private suspend fun sendFile(nodeId: String, file: File): AppResult { - val measurementName = file.parentFile?.name - ?: return AppResult.failure(AppError(AppErrorCode.STORAGE, "Read watch measurement folder")) - return transferClient.send( - nodeId = nodeId, - metadata = WearFileMetadata(measurementName, file.name), - input = file::inputStream, - ) - } - - private fun measurementFiles(): List { - val root = File(context.filesDir, APP_DIRECTORY) - return root.listFiles().orEmpty() - .filter(File::isDirectory) - .flatMap { folder -> folder.listFiles().orEmpty().asIterable() } - .filter { file -> file.isFile && file.extension.lowercase() in ALLOWED_EXTENSIONS } - .sortedBy(File::getAbsolutePath) - } - - private companion object { - const val APP_DIRECTORY = "SensorBox" - val ALLOWED_EXTENSIONS = setOf("csv", "json", "txt") - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt similarity index 66% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt index dd462c1..1128fd6 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardComponents.kt @@ -1,12 +1,11 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import androidx.compose.runtime.Composable -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearSecondaryButton +import com.tomasrepcik.sensorbox.design.WearSecondaryButton @Composable internal fun TransformingLazyColumnItemScope.WearBackButton( @@ -21,15 +20,6 @@ internal fun TransformingLazyColumnItemScope.WearBackButton( @Composable internal fun wearMessageText(message: WearDashboardMessage): String = when (message) { WearDashboardMessage.PickSource -> stringResource(R.string.message_pick_source) - WearDashboardMessage.PermissionRequired -> stringResource(R.string.message_permission_required) - WearDashboardMessage.SensorUnavailable -> stringResource(R.string.message_sensor_unavailable) - - WearDashboardMessage.Syncing -> stringResource(R.string.syncing) - - WearDashboardMessage.SyncFailed -> stringResource(R.string.message_sync_failed) - - is WearDashboardMessage.FilesSent -> - pluralStringResource(R.plurals.message_files_sent, message.count, message.count) } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt similarity index 72% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt index a8f4ef9..4078f25 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardContract.kt @@ -1,10 +1,10 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor enum class WearRoute { MENU, RECORD, LIVE, SETTINGS, ACTIVE } @@ -14,8 +14,10 @@ data class WearDashboardState( val selectedSensorIds: Set = emptySet(), val includesGps: Boolean = false, val liveSensorType: Int? = null, - val liveSamples: List = emptyList(), + val liveSamples: List> = emptyList(), val preferences: AppPreferences = AppPreferences(), + val activeSession: RecordingSessionState.Running? = null, + val isStopping: Boolean = false, val isSyncing: Boolean = false, val isWaitingForPermissions: Boolean = false, val message: WearDashboardMessage? = null, @@ -26,9 +28,6 @@ sealed interface WearDashboardMessage { data object PickSource : WearDashboardMessage data object PermissionRequired : WearDashboardMessage data object SensorUnavailable : WearDashboardMessage - data object Syncing : WearDashboardMessage - data object SyncFailed : WearDashboardMessage - data class FilesSent(val count: Int) : WearDashboardMessage } sealed interface WearDashboardIntent { @@ -44,7 +43,6 @@ sealed interface WearDashboardIntent { data object ToggleBatteryRestriction : WearDashboardIntent data object ToggleWakeLock : WearDashboardIntent data object ToggleDisplay : WearDashboardIntent - data object SyncMeasurements : WearDashboardIntent data object DismissFailure : WearDashboardIntent } @@ -56,19 +54,37 @@ sealed interface WearDashboardEffect { object WearDashboardReducer { fun reduce(state: WearDashboardState, intent: WearDashboardIntent): WearDashboardState = when (intent) { - is WearDashboardIntent.Open -> state.copy(route = intent.destination.toRoute(), message = null) - WearDashboardIntent.Back -> state.copy(route = WearRoute.MENU, message = null) + is WearDashboardIntent.Open -> state.copy( + route = intent.destination.toRoute(), + liveSensorType = null, + liveSamples = emptyList(), + message = null, + ) + + WearDashboardIntent.Back -> state.copy( + route = if (state.activeSession == null) WearRoute.MENU else WearRoute.ACTIVE, + message = null, + ) + is WearDashboardIntent.ToggleSensor -> state.toggleSensor(intent.sensorType) + WearDashboardIntent.ToggleGps -> state.copy(includesGps = !state.includesGps) + is WearDashboardIntent.ObserveSensor -> state.selectLiveSensor(intent.sensorType) + else -> state } - fun recordingSessionChanged(state: WearDashboardState, session: RecordingSessionState) = when { - session is RecordingSessionState.Running -> state.copy(route = WearRoute.ACTIVE) + fun recordingSessionChanged(state: WearDashboardState, session: RecordingSessionState) = when (session) { + is RecordingSessionState.Running -> state.copy( + route = WearRoute.ACTIVE, + activeSession = session, + isStopping = false, + ) - session is RecordingSessionState.Idle && state.route == WearRoute.ACTIVE -> - state.copy(route = WearRoute.MENU) + is RecordingSessionState.Stopping -> state.copy(isStopping = true) + is RecordingSessionState.Idle if state.route == WearRoute.ACTIVE -> + state.copy(route = WearRoute.MENU, activeSession = null, isStopping = false) else -> state } @@ -91,7 +107,6 @@ object WearDashboardReducer { private fun WearMenuDestination.toRoute(): WearRoute = when (this) { WearMenuDestination.RECORD -> WearRoute.RECORD WearMenuDestination.LIVE_SENSOR -> WearRoute.LIVE - WearMenuDestination.SYNC -> WearRoute.SETTINGS WearMenuDestination.SETTINGS -> WearRoute.SETTINGS else -> WearRoute.MENU } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt similarity index 59% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt index 54b4df0..a54966d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardScreen.kt @@ -1,8 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import androidx.compose.runtime.Composable -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuScreen -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuState +import com.tomasrepcik.sensorbox.diagnostics.WearAppErrorScreen +import com.tomasrepcik.sensorbox.menu.WearMenuScreen +import com.tomasrepcik.sensorbox.menu.WearMenuState +import com.tomasrepcik.sensorbox.recording.active.WearActiveScreen +import com.tomasrepcik.sensorbox.recording.live.WearLiveScreen +import com.tomasrepcik.sensorbox.recording.setup.WearRecordScreen +import com.tomasrepcik.sensorbox.settings.WearSettingsScreen @Composable fun WearDashboardScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt similarity index 80% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt index 9bc6605..d628cf1 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/home/WearDashboardViewModel.kt @@ -1,20 +1,20 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingPermissionUseCase -import com.tomasrepcik.sensorbox.domain.sensors.GetWatchSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.ObserveSensorValuesUseCase -import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock +import com.tomasrepcik.sensorbox.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.recording.DefaultWatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.WatchRecordingPermissionUseCase +import com.tomasrepcik.sensorbox.recording.live.ObserveSensorValuesUseCase import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.sources.GetWatchSensorsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -33,8 +33,8 @@ class WearDashboardViewModel @Inject constructor( private val recordingControl: DefaultWatchRecordingControlUseCase, private val preferencesRepository: AppPreferencesRepository, private val sessionStore: RecordingSessionStore, - private val syncMeasurements: SyncWatchMeasurementsUseCase, private val appFailures: AppFailureStore, + private val syncLock: MeasurementSyncLock = MeasurementSyncLock(), ) : ViewModel() { private val mutableState = MutableStateFlow(WearDashboardState(sensors = getSensors())) private val mutableEffects = Channel(Channel.BUFFERED) @@ -48,9 +48,18 @@ class WearDashboardViewModel @Inject constructor( observeSession() observeRecordingFailures() observeFailures() + viewModelScope.launch { + syncLock.busy.collect { busy -> + mutableState.value = mutableState.value.copy(isSyncing = busy) + } + } } fun accept(intent: WearDashboardIntent) { + if (intent is WearDashboardIntent.Open || intent == WearDashboardIntent.Back) { + sensorJob?.cancel() + sensorJob = null + } mutableState.value = WearDashboardReducer.reduce(mutableState.value, intent) intent.toPreferenceIntent()?.let { updatePreference(it) @@ -62,7 +71,6 @@ class WearDashboardViewModel @Inject constructor( is WearDashboardIntent.PermissionsResolved -> handlePermissionResult(intent.granted) WearDashboardIntent.StopRecording -> recordingControl.stop().showFailure() is WearDashboardIntent.ObserveSensor -> observeLiveSensor(intent.sensorType) - WearDashboardIntent.SyncMeasurements -> startSync() WearDashboardIntent.DismissFailure -> appFailures.dismiss() else -> Unit } @@ -87,7 +95,6 @@ class WearDashboardViewModel @Inject constructor( private fun handleDestination(destination: WearMenuDestination) { when (destination) { WearMenuDestination.PHONE_INFO -> mutableEffects.trySend(WearDashboardEffect.OpenPhone) - WearMenuDestination.SYNC -> startSync() WearMenuDestination.PRIVACY, WearMenuDestination.TERMS -> openUrl(destination) else -> Unit } @@ -135,8 +142,8 @@ class WearDashboardViewModel @Inject constructor( } } - private suspend fun publishSensorValue(value: Float) { - val samples = (mutableState.value.liveSamples + value).takeLast(MAX_SAMPLES) + private fun publishSensorValue(value: List) { + val samples = (mutableState.value.liveSamples + listOf(value)).takeLast(MAX_SAMPLES) mutableState.value = mutableState.value.copy(liveSamples = samples, message = null) } @@ -171,29 +178,12 @@ class WearDashboardViewModel @Inject constructor( viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } } - private fun startSync() { - if (mutableState.value.isSyncing) return - mutableState.value = mutableState.value.copy(isSyncing = true, message = WearDashboardMessage.Syncing) - viewModelScope.launch { - val result = syncMeasurements() - val message = result.fold( - onSuccess = WearDashboardMessage::FilesSent, - onFailure = { error -> - appFailures.show(error) - WearDashboardMessage.SyncFailed - }, - ) - mutableState.value = mutableState.value.copy(isSyncing = false, message = message) - } - } - private fun AppResult<*>.showFailure() { errorOrNull()?.let(::showOperationFailure) } private fun showOperationFailure(error: AppError) { appFailures.show(error) - mutableState.value = mutableState.value.copy(message = WearDashboardMessage.SyncFailed) } private fun observeFailures() = viewModelScope.launch { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt similarity index 93% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt index a321a29..78448de 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuContract.kt @@ -1,8 +1,7 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu enum class WearMenuDestination { RECORD, - SYNC, LIVE_SENSOR, PHONE_INFO, SETTINGS, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt similarity index 83% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt index 5b11d23..fa99d3f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuScreen.kt @@ -1,13 +1,13 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearNavigationRow -import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearNavigationRow +import com.tomasrepcik.sensorbox.design.WearPageTitle @Composable fun WearMenuScreen(state: WearMenuState, onDestinationSelected: (WearMenuDestination) -> Unit) { @@ -28,7 +28,6 @@ fun WearMenuScreen(state: WearMenuState, onDestinationSelected: (WearMenuDestina @StringRes private fun WearMenuDestination.labelResource(): Int = when (this) { WearMenuDestination.RECORD -> R.string.activity_record - WearMenuDestination.SYNC -> R.string.sync_recordings WearMenuDestination.LIVE_SENSOR -> R.string.activity_view_sensor WearMenuDestination.PHONE_INFO -> R.string.activity_info_phone WearMenuDestination.SETTINGS -> R.string.activity_settings @@ -39,7 +38,6 @@ private fun WearMenuDestination.labelResource(): Int = when (this) { @DrawableRes private fun WearMenuDestination.iconResource(): Int = when (this) { WearMenuDestination.RECORD -> R.drawable.ic_record - WearMenuDestination.SYNC -> R.drawable.ic_sync WearMenuDestination.LIVE_SENSOR -> R.drawable.ic_live WearMenuDestination.PHONE_INFO -> R.drawable.ic_phone WearMenuDestination.SETTINGS -> R.drawable.ic_settings diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt index a45bc11..294394a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/menu/WearMenuViewModel.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt new file mode 100644 index 0000000..01deab2 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/MsgListener.kt @@ -0,0 +1,39 @@ +package com.tomasrepcik.sensorbox.pairedrecording + +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.WearableListenerService +import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock +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.sync.WearTransferWorkService +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@AndroidEntryPoint +class MsgListener : WearableListenerService() { + @Inject + lateinit var messageDispatcher: WearMessageDispatcher + + @Inject + lateinit var syncLock: MeasurementSyncLock + + override fun onMessageReceived(messageEvent: MessageEvent) { + val path = messageEvent.path + val payload = messageEvent.data.copyOf() + val command = WearCommandCodec.decode(payload).getOrNull() + if (path == WEAR_MESSAGE_PATH && command is WearCommand.CopyWatchMeasurements) { + syncLock.begin(command.requestId) + WearTransferWorkService.enqueue(this) { + try { + messageDispatcher.dispatch(path, payload) + } finally { + syncLock.finish(command.requestId) + } + }.onFailure { syncLock.finish(command.requestId) } + } else { + runBlocking { messageDispatcher.dispatch(path, payload) } + } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt similarity index 58% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt index 806bb43..85c4113 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/RecordingCommandExchangeModule.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandExchange -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender -import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt index 3bcc0ac..eb93766 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandEnvironment.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandEnvironment.kt @@ -1,15 +1,15 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingPermissionUseCase -import com.tomasrepcik.sensorbox.domain.sensors.GetWatchSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.toWatchSensorInfo -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.recording.WatchRecordingPermissionUseCase +import com.tomasrepcik.sensorbox.recording.sources.GetWatchSensorsUseCase +import com.tomasrepcik.sensorbox.recording.sources.toWatchSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo import kotlinx.coroutines.flow.first import javax.inject.Inject diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt similarity index 57% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt index 1b1bdb4..a11d35e 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearCommandHandler.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandler.kt @@ -1,24 +1,30 @@ -package com.tomasrepcik.sensorbox.communication - -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import kotlinx.coroutines.withTimeoutOrNull +package com.tomasrepcik.sensorbox.pairedrecording + +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOperation +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import javax.inject.Inject +import javax.inject.Singleton +@Singleton class WearCommandHandler @Inject constructor( private val recording: WatchRecordingControlUseCase, private val environment: WearRecordingRequirementsUseCase, private val sendCommand: SendWearCommandUseCase, - private val phoneResults: PhoneRecordingResultInbox, + private val recordingCommands: RecordingCommandSender, + private val recordingResults: RecordingResultReceiver, + private val syncMeasurements: SyncWatchMeasurementsUseCase, ) { private val completedResults = mutableMapOf() private var activeSessionId: String? = null @@ -30,50 +36,49 @@ class WearCommandHandler @Inject constructor( WearCommand.RequestAvailableSensors -> sendAvailableSensors() + is WearCommand.CancelWatchSync -> { + syncMeasurements.cancel(command.requestId) + AppResult.success(Unit) + } + + is WearCommand.CheckWatchMeasurements -> sendMeasurementStatus(command.requestId, false) + + is WearCommand.CopyWatchMeasurements -> sendMeasurementStatus(command.requestId, true) + + is WearCommand.WatchMeasurementsStatus -> AppResult.success(Unit) + is WearCommand.RecordingResult -> { - phoneResults.publish(command) + recordingResults.receive(command) AppResult.success(Unit) } WearCommand.LaunchPhone, - WearCommand.SyncMeasurements, is WearCommand.AvailableSensors, -> AppResult.success(Unit) } + private suspend fun sendMeasurementStatus(requestId: String, copy: Boolean): AppResult { + val available = syncMeasurements.available() + val transferred = if (copy && available.isSuccess) syncMeasurements(requestId) else null + val remaining = if (transferred?.isFailure == true) syncMeasurements.available() else available + val counts = remaining.getOrNull() + return sendCommand( + PHONE_APP_CAPABILITY, + PHONE_MESSAGE_PATH, + WearCommand.WatchMeasurementsStatus( + requestId = requestId, + fileCount = transferred?.getOrNull() ?: counts?.first ?: 0, + measurementCount = minOf(counts?.second ?: 0, transferred?.getOrNull() ?: Int.MAX_VALUE), + finished = copy, + failed = available.isFailure || transferred?.isFailure == true, + ), + ) + } + suspend fun onAutomaticStop(reason: WearStopReason): AppResult { val sessionId = activeSessionId ?: return AppResult.success(Unit) activeSessionId = null - val command = WearCommand.StopRecording(sessionId, reason) - phoneResults.clear(sessionId, WearRecordingOperation.STOP) - var lastSendError: AppError? = null - - repeat(ATTEMPT_COUNT) { retryCount -> - when (val sent = sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, command)) { - is AppResult.Failure -> lastSendError = sent.error - - is AppResult.Success -> { - lastSendError = null - val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) { - phoneResults.await(sessionId, WearRecordingOperation.STOP) - } - if (result != null) return result.toAppResult(retryCount) - } - } - } - - return lastSendError?.let { error -> AppResult.failure(error) } ?: AppResult.failure( - AppError( - code = AppErrorCode.TIMEOUT, - operation = "Propagate automatic Wear stop", - diagnosticMessage = "Phone stop result timed out", - context = mapOf( - "sessionId" to sessionId, - "retryCount" to RETRY_COUNT.toString(), - ), - isRetryable = true, - ), - ) + return recordingCommands.send(WearCommand.StopRecording(sessionId, reason)) } private suspend fun start(command: WearCommand.StartRecording): WearCommand.RecordingResult { @@ -154,30 +159,5 @@ class WearCommandHandler @Inject constructor( private fun cached(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult? = completedResults[ResultKey(sessionId, operation)] - private fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult = - if (outcome == WearRecordingOutcome.SUCCEEDED) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError( - code = errorCode ?: AppErrorCode.UNKNOWN, - operation = errorOperation ?: "Handle phone $operation result", - diagnosticMessage = errorMessage ?: "Phone $operation failed", - context = errorContext + mapOf( - "source" to "phone", - "sessionId" to sessionId, - "retryCount" to retryCount.toString(), - "failureCount" to failureCount.toString(), - ), - ), - ) - } - private data class ResultKey(val sessionId: String, val operation: WearRecordingOperation) - - private companion object { - const val RESULT_TIMEOUT_MILLIS = 5_000L - const val RETRY_COUNT = 2 - const val ATTEMPT_COUNT = RETRY_COUNT + 1 - } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt similarity index 53% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt index e76018c..84746d4 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearMessageDispatcher.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearMessageDispatcher.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.suspendFlatMap -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec import javax.inject.Inject class WearMessageDispatcher @Inject constructor( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt similarity index 78% rename from wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt index 16ace59..a9ee41e 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/pairedrecording/WearRecordingSessionObserver.kt @@ -1,11 +1,11 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopReason -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopped -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.toDiagnosticEvent +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopped +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt index 68c620d..2981c01 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchContract.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode enum class PhoneLaunchStatus { IDLE, diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt new file mode 100644 index 0000000..1c3c57f --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchScreen.kt @@ -0,0 +1,51 @@ +package com.tomasrepcik.sensorbox.phonelaunch + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.diagnostics.WearAppErrorScreen + +@Composable +fun PhoneLaunchScreen(state: PhoneLaunchState, onIntent: (PhoneLaunchIntent) -> Unit) { + state.visibleFailureCode?.let { code -> + WearAppErrorScreen(code) { onIntent(PhoneLaunchIntent.DismissFailure) } + return + } + Box( + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier.fillMaxWidth(0.7f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.activity_info_phone), textAlign = TextAlign.Center) + Text(stringResource(statusText(state.status)), textAlign = TextAlign.Center) + Button( + onClick = { onIntent(PhoneLaunchIntent.LaunchRequested) }, + enabled = state.isPhoneConnected && state.status != PhoneLaunchStatus.LAUNCHING, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.open_phone), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt index a662034..11efc9f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchStatusText.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import com.tomasrepcik.sensorbox.R diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt similarity index 75% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt index b658f6f..1546c7f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModel.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt deleted file mode 100644 index 48abaa2..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -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.unit.dp -import androidx.wear.compose.material3.Button -import androidx.wear.compose.material3.ButtonDefaults -import androidx.wear.compose.material3.MaterialTheme -import androidx.wear.compose.material3.Text -import com.tomasrepcik.sensorbox.R - -@Composable -internal fun WearRecordingStatus(selectedCount: Int, keepsDisplayOn: Boolean) { - Text( - text = stringResource(R.string.recording), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(18.dp)) - Text( - text = pluralStringResource(R.plurals.source_count, selectedCount, selectedCount), - style = MaterialTheme.typography.displaySmall, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(if (keepsDisplayOn) R.string.display_stays_on else R.string.display_may_sleep), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -@Composable -internal fun WearStopRecordingButton(onStop: () -> Unit) { - Button( - onClick = onStop, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.stop_and_save)) - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt deleted file mode 100644 index 942e1f3..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.wear.compose.material3.AppScaffold -import androidx.wear.compose.material3.ScreenScaffold - -@Composable -internal fun WearActiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { - val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 - AppScaffold { - ScreenScaffold { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 28.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - WearRecordingStatus( - selectedCount = selectedCount, - keepsDisplayOn = state.preferences.display.keepWearDisplayOn, - ) - Spacer(Modifier.height(22.dp)) - WearStopRecordingButton { accept(WearDashboardIntent.StopRecording) } - } - } - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt deleted file mode 100644 index 9fac904..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer -import com.patrykandpatrick.vico.compose.cartesian.data.lineModel - -@Composable -internal fun WearLiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { - val chartModelProducer = remember { CartesianChartModelProducer() } - LaunchedEffect(state.liveSamples) { - chartModelProducer.runTransaction { lineModel { series(state.liveSamples) } } - } - val selectedSensor = state.sensors.firstOrNull { it.type == state.liveSensorType } - if (selectedSensor == null) { - WearSensorPicker(state.sensors, accept) - } else { - WearLiveSensor(selectedSensor, state.liveSamples.lastOrNull(), chartModelProducer, accept) - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt deleted file mode 100644 index 38ece7c..0000000 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle -import com.tomasrepcik.sensorbox.presentation.dashboard.WearAppErrorScreen - -@Composable -fun PhoneLaunchScreen(state: PhoneLaunchState, onIntent: (PhoneLaunchIntent) -> Unit) { - state.visibleFailureCode?.let { code -> - WearAppErrorScreen(code) { onIntent(PhoneLaunchIntent.DismissFailure) } - return - } - WearListScreen { transformation -> - item { WearPageTitle(stringResource(R.string.activity_info_phone), transformation) } - item { WearSectionTitle(stringResource(statusText(state.status)), transformation) } - item { - WearPrimaryButton( - label = stringResource(R.string.open_phone), - transformation = transformation, - onClick = { onIntent(PhoneLaunchIntent.LaunchRequested) }, - enabled = state.isPhoneConnected && state.status != PhoneLaunchStatus.LAUNCHING, - ) - } - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferences.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferences.kt new file mode 100644 index 0000000..a0c706d --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferences.kt @@ -0,0 +1,17 @@ +package com.tomasrepcik.sensorbox.recording + +import com.tomasrepcik.sensorbox.core.preferences.AppPreferences +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest + +internal fun WearRecordingRequest.recordingPreferences(watchPreferences: AppPreferences): AppPreferences { + val requested = settings + return watchPreferences.copy( + recording = watchPreferences.recording.copy( + sensorSamplingPeriod = requested.samplingPeriodIndex, + stopRecordingOnLowBattery = requested.stopOnLowBattery, + useWakeLock = requested.useWakeLock, + gpsIntervalSeconds = requested.gpsIntervalSeconds, + gpsMinDistanceMeters = requested.gpsMinDistanceMeters, + ), + ) +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt similarity index 61% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt index 53cdc6b..aa6f61f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/RecordingModule.kt @@ -1,9 +1,9 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.recording -import com.tomasrepcik.sensorbox.communication.WearRecordingEnvironment -import com.tomasrepcik.sensorbox.communication.WearRecordingRequirementsUseCase -import com.tomasrepcik.sensorbox.domain.recording.DefaultWatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingEnvironment +import com.tomasrepcik.sensorbox.pairedrecording.WearRecordingRequirementsUseCase +import com.tomasrepcik.sensorbox.recording.DefaultWatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt similarity index 77% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt index 83ab4cf..88a4754 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingControlUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingControlUseCase.kt @@ -1,18 +1,19 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.content.Context import android.content.Intent import android.hardware.SensorManager import androidx.core.content.ContextCompat -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingIntentFactory -import com.tomasrepcik.sensorbox.sensorservices.intent.RecordingRequest -import com.tomasrepcik.sensorbox.sensorservices.services.RecordingService -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingIntentFactory +import com.tomasrepcik.sensorbox.recordinghost.request.RecordingRequest +import com.tomasrepcik.sensorbox.recordinghost.session.RecordingService +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -25,6 +26,7 @@ interface WatchRecordingControlUseCase { class DefaultWatchRecordingControlUseCase @Inject constructor( @ApplicationContext private val context: Context, private val intentFactory: RecordingIntentFactory, + private val syncLock: MeasurementSyncLock = MeasurementSyncLock(), ) : WatchRecordingControlUseCase { override fun start( sessionId: String, @@ -34,7 +36,7 @@ class DefaultWatchRecordingControlUseCase @Inject constructor( sessionId = sessionId, sensorIds = request.sensorIds.toSet(), includesGps = request.includesGps, - preferences = preferences, + preferences = request.recordingPreferences(preferences), folderName = request.folderName, durationMillis = request.durationMillis, ) @@ -60,7 +62,7 @@ class DefaultWatchRecordingControlUseCase @Inject constructor( gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, durationMillis = durationMillis, ) - ContextCompat.startForegroundService(context, intentFactory.create(request)) + syncLock.whenIdle { ContextCompat.startForegroundService(context, intentFactory.create(request)) } } fun stop(): AppResult = appResult(AppErrorCode.RECORDING, "Request watch recording stop") { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt similarity index 94% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt index fa12ec1..6df6dab 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest import android.content.Context diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt new file mode 100644 index 0000000..2e0ef29 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveComponents.kt @@ -0,0 +1,92 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.home.WearDashboardState + +@Composable +internal fun WearRecordingStatus(state: WearDashboardState) { + val session = state.activeSession ?: return + val sourceCount = session.sensorIds.size + if (session.includesGps) 1 else 0 + Text( + text = stringResource(R.string.recording), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pluralStringResource(R.plurals.source_count, sourceCount, sourceCount), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(if (session.includesGps) R.string.recording_gps_on else R.string.recording_gps_off), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = if (session.durationMillis > 0) { + stringResource(R.string.recording_duration_seconds, session.durationMillis / 1_000) + } else { + stringResource(R.string.recording_until_stopped) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(samplingLabel(session.sensorSamplingPeriod)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + ) + state.sensors.filter { it.type in session.sensorIds }.forEach { sensor -> + Text( + sensor.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } +} + +private fun samplingLabel(period: Int): Int = when (period) { + 1 -> R.string.sampling_game + 2 -> R.string.sampling_ui + 3 -> R.string.sampling_normal + else -> R.string.sampling_fastest +} + +@Composable +internal fun WearStopRecordingButton(isStopping: Boolean, modifier: Modifier = Modifier, onStop: () -> Unit) { + val description = stringResource(R.string.stop_and_save) + Button( + onClick = onStop, + enabled = !isStopping, + modifier = modifier.height(52.dp).semantics { contentDescription = description }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFB3261E), + contentColor = Color.White, + ), + ) { + Text( + stringResource(if (isStopping) R.string.recording_stopping else R.string.stop_recording), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt new file mode 100644 index 0000000..12289ce --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/active/WearActiveScreen.kt @@ -0,0 +1,41 @@ +package com.tomasrepcik.sensorbox.recording.active + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.wear.compose.material3.MaterialTheme +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState + +@Composable +internal fun WearActiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = maxHeight * 0.14f) + .fillMaxWidth(0.70f) + .height(maxHeight * 0.35f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + WearRecordingStatus(state) + } + WearStopRecordingButton( + isStopping = state.isStopping, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = maxHeight * 0.20f) + .fillMaxWidth(0.68f), + ) { accept(WearDashboardIntent.StopRecording) } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt similarity index 82% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt index 636038b..f57e120 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/ObserveSensorValuesUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/ObserveSensorValuesUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.live import android.content.Context import android.hardware.Sensor @@ -15,7 +15,7 @@ import javax.inject.Inject class ObserveSensorValuesUseCase @Inject constructor(@ApplicationContext context: Context) { private val sensorManager = context.getSystemService(SensorManager::class.java) - operator fun invoke(sensorType: Int): Flow = callbackFlow { + operator fun invoke(sensorType: Int): Flow> = callbackFlow { val sensor = sensorManager.getDefaultSensor(sensorType) if (sensor == null) { close(IllegalArgumentException("Sensor type $sensorType is unavailable")) @@ -31,9 +31,9 @@ class ObserveSensorValuesUseCase @Inject constructor(@ApplicationContext context awaitClose { sensorManager.unregisterListener(listener) } }.conflate() - private fun sensorListener(onValue: (Float) -> Unit) = object : SensorEventListener { + private fun sensorListener(onValue: (List) -> Unit) = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { - event.values.firstOrNull()?.let(onValue) + if (event.values.isNotEmpty()) onValue(event.values.toList()) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt similarity index 69% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt index 2a2f969..25b81b9 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.live import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,7 +11,10 @@ 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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.material3.MaterialTheme @@ -22,17 +25,20 @@ import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.common.vicoTheme import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearNavigationRow -import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearNavigationRow +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @Composable internal fun WearLiveSensor( sensor: WatchSensorDescriptor, - latestValue: Float?, + latestValue: List?, chartModelProducer: CartesianChartModelProducer, accept: (WearDashboardIntent) -> Unit, ) { @@ -54,7 +60,7 @@ internal fun WearLiveSensor( } @Composable -private fun WearLiveReading(sensorName: String, latestValue: Float?) { +private fun WearLiveReading(sensorName: String, latestValue: List?) { Text( text = sensorName, style = MaterialTheme.typography.titleMedium, @@ -62,10 +68,24 @@ private fun WearLiveReading(sensorName: String, latestValue: Float?) { maxLines = 2, ) Spacer(Modifier.height(4.dp)) + val colors = vicoTheme.lineCartesianLayerColors + val waiting = stringResource(R.string.waiting) + val reading = buildAnnotatedString { + latestValue?.forEachIndexed { index, value -> + if (index > 0) append(" ") + withStyle(SpanStyle(color = colors[index % colors.size])) { + if (latestValue.size > 1) { + append(listOf("X", "Y", "Z").getOrElse(index) { "V${index + 1}" }) + append(": ") + } + append(ValueFormats.decimal(value)) + } + } + } Text( - text = latestValue?.let(ValueFormats::decimal) ?: stringResource(R.string.waiting), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.primary, + text = if (latestValue == null) buildAnnotatedString { append(waiting) } else reading, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, ) } @@ -74,6 +94,8 @@ private fun WearLiveChart(chartModelProducer: CartesianChartModelProducer) { CartesianChartHost( chart = rememberCartesianChart(rememberLineCartesianLayer()), modelProducer = chartModelProducer, + animationSpec = null, + initialAnimationSpec = null, modifier = Modifier.fillMaxWidth().height(82.dp), ) } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt new file mode 100644 index 0000000..fa2617f --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/live/WearLiveScreen.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.recording.live + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.lineModel +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState + +@Composable +internal fun WearLiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + val chartModelProducer = remember(state.liveSensorType) { CartesianChartModelProducer() } + LaunchedEffect(chartModelProducer, state.liveSamples) { + if (state.liveSamples.isNotEmpty() && state.liveSamples.all { it.isNotEmpty() }) { + chartModelProducer.runTransaction { + lineModel { + val axisCount = state.liveSamples.minOf { it.size } + repeat(axisCount) { axis -> series(state.liveSamples.map { it[axis] }) } + } + } + } + } + val selectedSensor = state.sensors.firstOrNull { it.type == state.liveSensorType } + if (selectedSensor == null) { + WearSensorPicker(state.sensors, accept) + } else { + key(state.liveSensorType) { + WearLiveSensor(selectedSensor, state.liveSamples.lastOrNull(), chartModelProducer, accept) + } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt similarity index 63% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt index 5bbe3f8..cc2263b 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordComponents.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource @@ -6,15 +6,24 @@ import androidx.compose.ui.res.stringResource import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.WearCheckboxRow -import com.tomasrepcik.sensorbox.presentation.WearPrimaryEdgeButton +import com.tomasrepcik.sensorbox.design.WearCheckboxRow +import com.tomasrepcik.sensorbox.design.WearPrimaryEdgeButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @Composable -internal fun WearRecordingStartButton(selectedCount: Int, accept: (WearDashboardIntent) -> Unit) { +internal fun WearRecordingStartButton( + selectedCount: Int, + accept: (WearDashboardIntent) -> Unit, + syncing: Boolean = false, +) { WearPrimaryEdgeButton( - label = pluralStringResource(R.plurals.start_source_count, selectedCount, selectedCount), - enabled = selectedCount > 0, + label = if (syncing) { + androidx.compose.ui.res.stringResource(R.string.wait_for_watch_sync) + } else { + pluralStringResource(R.plurals.start_source_count, selectedCount, selectedCount) + }, + enabled = selectedCount > 0 && !syncing, onClick = { accept(WearDashboardIntent.StartRecording) }, ) } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt similarity index 71% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt index 100e207..005231d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/setup/WearRecordScreen.kt @@ -1,17 +1,21 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.recording.setup import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearSectionTitle +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState +import com.tomasrepcik.sensorbox.home.wearMessageText @Composable internal fun WearRecordScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 WearListScreen( - edgeButton = { WearRecordingStartButton(selectedCount, accept) }, + edgeButton = { WearRecordingStartButton(selectedCount, accept, state.isSyncing) }, ) { transformation -> item { WearPageTitle(stringResource(R.string.sources), transformation) } item { WearGpsSourceRow(state.includesGps, transformation) { accept(WearDashboardIntent.ToggleGps) } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt similarity index 96% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt index fa0657b..e46e7ae 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/GetWatchSensorsUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/GetWatchSensorsUseCase.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.content.Context import android.hardware.Sensor diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt similarity index 86% rename from wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt index 1055a61..fac5889 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorDescriptor.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorDescriptor.kt @@ -1,6 +1,6 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo data class WatchSensorDescriptor( val type: Int, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt similarity index 95% rename from app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt index 1b3ee8b..36117b2 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/PreferencesModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/PreferencesModule.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.settings import android.content.Context import androidx.datastore.core.DataStore diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt similarity index 66% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt index c0f5b3f..95ba88c 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/settings/WearSettingsScreen.kt @@ -1,21 +1,19 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.settings -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope -import androidx.wear.compose.material3.CircularProgressIndicator import androidx.wear.compose.material3.lazy.TransformationSpec import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton -import com.tomasrepcik.sensorbox.presentation.WearRadioRow -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle -import com.tomasrepcik.sensorbox.presentation.WearSwitchRow +import com.tomasrepcik.sensorbox.design.WearListScreen +import com.tomasrepcik.sensorbox.design.WearPageTitle +import com.tomasrepcik.sensorbox.design.WearRadioRow +import com.tomasrepcik.sensorbox.design.WearSectionTitle +import com.tomasrepcik.sensorbox.design.WearSwitchRow +import com.tomasrepcik.sensorbox.home.WearBackButton +import com.tomasrepcik.sensorbox.home.WearDashboardIntent +import com.tomasrepcik.sensorbox.home.WearDashboardState +import com.tomasrepcik.sensorbox.home.wearMessageText @Composable internal fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { @@ -23,7 +21,6 @@ internal fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboar item { WearPageTitle(stringResource(R.string.activity_settings), transformation) } samplingPreferenceItems(state, transformation, accept) recordingPreferenceItems(state, transformation, accept) - measurementTransferItems(state, transformation, accept) state.message?.let { message -> item { WearSectionTitle(wearMessageText(message), transformation) } } @@ -84,21 +81,3 @@ private fun TransformingLazyColumnScope.recordingPreferenceItems( ) } } - -private fun TransformingLazyColumnScope.measurementTransferItems( - state: WearDashboardState, - transformation: TransformationSpec, - accept: (WearDashboardIntent) -> Unit, -) { - item { WearSectionTitle(stringResource(R.string.transfer), transformation) } - item { - WearPrimaryButton( - label = stringResource(if (state.isSyncing) R.string.syncing else R.string.sync_recordings), - transformation = transformation, - enabled = !state.isSyncing, - ) { accept(WearDashboardIntent.SyncMeasurements) } - } - if (state.isSyncing) { - item { CircularProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) } - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt similarity index 66% rename from wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt index 9ece377..6022a97 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncModule.kt @@ -1,7 +1,7 @@ -package com.tomasrepcik.sensorbox.di +package com.tomasrepcik.sensorbox.sync -import com.tomasrepcik.sensorbox.domain.sync.DefaultSyncWatchMeasurementsUseCase -import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.sync.DefaultSyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt new file mode 100644 index 0000000..9597a1b --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/SyncWatchMeasurementsUseCase.kt @@ -0,0 +1,114 @@ +package com.tomasrepcik.sensorbox.sync + +import android.content.Context +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult +import com.tomasrepcik.sensorbox.core.failure.appResult +import com.tomasrepcik.sensorbox.core.failure.suspendAppResult +import com.tomasrepcik.sensorbox.core.failure.suspendFlatMap +import com.tomasrepcik.sensorbox.core.storage.MeasurementSyncLock +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +interface SyncWatchMeasurementsUseCase { + suspend operator fun invoke(requestId: String): AppResult + + fun cancel(requestId: String) + + fun available(): AppResult> +} + +@Singleton +class DefaultSyncWatchMeasurementsUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val connectionRepository: WearConnectionRepository, + private val transferMeasurement: TransferWatchMeasurementUseCase, + private val sessions: RecordingSessionStore, + private val syncLock: MeasurementSyncLock, + private val transferClient: WearFileTransferClient, +) : SyncWatchMeasurementsUseCase { + private val transferLock = Mutex() + private val jobs = ConcurrentHashMap() + + override fun cancel(requestId: String) { + transferClient.cancel(requestId) + jobs[requestId]?.cancel() + } + + override fun available(): AppResult> = appResult(AppErrorCode.STORAGE, "List watch measurements") { + val files = measurementFiles() + files.size to files.map { it.parent }.distinct().size + } + + override suspend fun invoke(requestId: String): AppResult { + syncLock.begin(requestId) + try { + return withContext(Dispatchers.IO) { + jobs[requestId] = checkNotNull(currentCoroutineContext()[Job]) + withTimeoutOrNull(280_000L.milliseconds) { + transferLock.withLock { transferMeasurements(requestId) } + } ?: AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Watch sync timed out")) + } + } finally { + jobs.remove(requestId) + transferClient.cancel(requestId) + syncLock.finish(requestId) + } + } + + private suspend fun transferMeasurements(requestId: String): AppResult = + suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") { + connectionRepository.findNode(PHONE_APP_CAPABILITY) + }.suspendFlatMap { node -> + if (node == null) { + return@suspendFlatMap AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find connected phone")) + } + appResult(AppErrorCode.STORAGE, "List watch measurements", ::measurementFiles).suspendFlatMap { files -> + transferFolders(node.id, files, requestId) + } + } + + private suspend fun transferFolders(nodeId: String, files: List, requestId: String): AppResult { + var transferred = 0 + for (measurement in files.groupBy { it.parent }.values) { + val alreadyConfirmed = WatchMeasurementCleanup().isConfirmed(checkNotNull(measurement.first().parentFile)) + val result = transferMeasurement(nodeId, measurement, requestId) + if (result.isFailure) return result.map { 0 } + if (!alreadyConfirmed) transferred += measurement.size + } + return AppResult.success(transferred) + } + + private fun measurementFiles(): List { + val session = sessions.state.value + if (session == RecordingSessionState.Stopping) return emptyList() + val activeFolder = (session as? RecordingSessionState.Running)?.folderName + return File(context.filesDir, APP_DIRECTORY).listFiles().orEmpty() + .filter { it.isDirectory && it.name != activeFolder } + .flatMap { folder -> folder.listFiles().orEmpty().asIterable() } + .filter { file -> file.isFile && file.extension.lowercase() in ALLOWED_EXTENSIONS } + .sortedBy(File::getAbsolutePath) + } + + private companion object { + const val APP_DIRECTORY = "SensorBox" + val ALLOWED_EXTENSIONS = setOf("csv", "json", "txt") + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCase.kt new file mode 100644 index 0000000..816643b --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCase.kt @@ -0,0 +1,72 @@ +package com.tomasrepcik.sensorbox.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.measurementFingerprint +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.io.File +import javax.inject.Inject + +class TransferWatchMeasurementUseCase @Inject constructor( + private val transferClient: WearFileTransferClient, + private val sessions: RecordingSessionStore, +) { + private val cleanup = WatchMeasurementCleanup() + + @Suppress("ReturnCount") + suspend operator fun invoke(nodeId: String, files: List, requestId: String): AppResult { + val folder = checkNotNull(files.first().parentFile) + if (cleanup.isConfirmed(folder)) { + return appResult(AppErrorCode.STORAGE, "Resume confirmed watch cleanup") { + check(!isRecording(folder.name)) { "Measurement is still recording" } + cleanup.finish(folder) + } + } + val snapshot = appResult(AppErrorCode.STORAGE, "Read watch measurement before transfer") { + check(!isRecording(folder.name)) { "Measurement is still recording" } + fingerprint(files) + } + if (snapshot.isFailure) return snapshot.map { } + for (file in files) { + val result = transferClient.send( + nodeId, + WearFileMetadata(folder.name, file.name, requestId), + file::inputStream, + ) + if (result.isFailure) return result + } + currentCoroutineContext().ensureActive() + val unchanged = appResult(AppErrorCode.STORAGE, "Verify watch measurement before publishing") { + check(!isRecording(folder.name)) { "Measurement is still recording" } + check(fingerprint(files) == snapshot.getOrNull()) { "Measurement changed during transfer" } + } + if (unchanged.isFailure) return unchanged + val committed = transferClient.send( + nodeId, + WearFileMetadata(folder.name, WearFileMetadata.COMMIT_FILE, requestId), + ) { checkNotNull(snapshot.getOrNull()).byteInputStream() } + if (committed.isFailure) return committed + currentCoroutineContext().ensureActive() + return appResult(AppErrorCode.STORAGE, "Remove confirmed watch measurement") { + check(!isRecording(folder.name)) { "Measurement is still recording" } + check(fingerprint(files) == snapshot.getOrNull()) { "Measurement changed during transfer" } + cleanup.confirm(folder, files) + cleanup.finish(folder) + } + } + + private fun fingerprint(files: List): String = + measurementFingerprint(files.map { it.name to it::inputStream }) + + private fun isRecording(folderName: String): Boolean = when (val session = sessions.state.value) { + is RecordingSessionState.Running -> session.folderName == folderName + RecordingSessionState.Stopping -> true + RecordingSessionState.Idle -> false + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/sync/WatchMeasurementCleanup.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/WatchMeasurementCleanup.kt new file mode 100644 index 0000000..2629f17 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/sync/WatchMeasurementCleanup.kt @@ -0,0 +1,47 @@ +package com.tomasrepcik.sensorbox.sync + +import com.tomasrepcik.sensorbox.core.storage.measurementFingerprint +import java.io.File +import java.util.Properties + +/** Remembers a committed phone copy before deleting any watch file. */ +internal class WatchMeasurementCleanup { + fun isConfirmed(folder: File): Boolean = File(folder, RECEIPT).isFile + + fun confirm(folder: File, files: List) { + val fingerprints = Properties() + files.forEach { file -> + fingerprints.setProperty(file.name, measurementFingerprint(listOf(file.name to file::inputStream))) + } + val temporary = File(folder, "$RECEIPT.tmp") + temporary.outputStream().use { output -> + fingerprints.store(output, null) + output.fd.sync() + } + check(temporary.renameTo(File(folder, RECEIPT))) { "Could not save watch cleanup receipt" } + } + + fun finish(folder: File) { + val receipt = File(folder, RECEIPT) + val fingerprints = Properties().apply { receipt.inputStream().use(::load) } + fingerprints.stringPropertyNames().forEach { name -> + val expected = fingerprints.getProperty(name) + check(name == File(name).name && name != "." && name != "..") { "Invalid watch cleanup receipt" } + val file = File(folder, name) + if (file.exists()) { + check(measurementFingerprint(listOf(name to file::inputStream)) == expected) { + "Confirmed watch file changed before cleanup" + } + check(file.delete()) { "Could not delete confirmed file $name" } + } + } + check(receipt.delete()) { "Could not delete watch cleanup receipt" } + if (folder.listFiles()?.isEmpty() == true) { + check(folder.delete()) { "Could not delete confirmed measurement folder" } + } + } + + private companion object { + const val RECEIPT = ".sensorbox-confirmed.txt" + } +} diff --git a/wear/src/main/res/values/recording_active.xml b/wear/src/main/res/values/recording_active.xml new file mode 100644 index 0000000..e7ab52f --- /dev/null +++ b/wear/src/main/res/values/recording_active.xml @@ -0,0 +1,8 @@ + + GPS on + GPS off + Duration: %1$d s + Until stopped + Saving… + Stop + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 0a1884a..5e6d2c5 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -27,7 +27,6 @@ GPS Sources Live values - Start recording Back Waiting… Sync recordings @@ -46,8 +45,6 @@ Display stays on Display may sleep Stop & save - - Pick at least one sensor or GPS Permission required Sensor unavailable @@ -67,5 +64,4 @@ https://tomasrepcik.dev/sensorbox/terms-of-use https://tomasrepcik.dev/sensorbox/privacy-policy - Could not open the phone browser. The error was saved. diff --git a/wear/src/main/res/values/watch_sync.xml b/wear/src/main/res/values/watch_sync.xml new file mode 100644 index 0000000..b487866 --- /dev/null +++ b/wear/src/main/res/values/watch_sync.xml @@ -0,0 +1,3 @@ + + Wait for watch sync + diff --git a/wear/src/main/res/xml/backup_descriptor.xml b/wear/src/main/res/xml/backup_descriptor.xml new file mode 100644 index 0000000..ce0b324 --- /dev/null +++ b/wear/src/main/res/xml/backup_descriptor.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/wear/src/main/res/xml/data_extraction_rules.xml b/wear/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..b853a8d --- /dev/null +++ b/wear/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt similarity index 83% rename from wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt rename to wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt index c5a6d6a..fc0e37a 100644 --- a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt +++ b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviews.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import android.content.res.Configuration import androidx.compose.foundation.background @@ -17,25 +17,26 @@ import com.android.tools.screenshot.PreviewTest import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.DisplayPreferences import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.design.WearSensorBoxTheme +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.sources.WatchSensorDescriptor @PreviewTest @ReadmeWearPreview @Composable -fun readmeWearDashboard() = WearReadmeFrame(WearDashboardState(route = WearRoute.MENU)) +fun ReadmeWearDashboard() = WearReadmeFrame(WearDashboardState(route = WearRoute.MENU)) @PreviewTest @ReadmeWearPreview @Composable -fun readmeWearLivePicker() = WearReadmeFrame( +fun ReadmeWearLivePicker() = WearReadmeFrame( WearDashboardState(route = WearRoute.LIVE, sensors = readmeWearSensors()), ) @PreviewTest @ReadmeWearPreview @Composable -fun wearRecordSelection() = WearReadmeFrame( +fun WearRecordSelection() = WearReadmeFrame( WearDashboardState( route = WearRoute.RECORD, sensors = readmeWearSensors(), @@ -47,7 +48,7 @@ fun wearRecordSelection() = WearReadmeFrame( @PreviewTest @ReadmeWearPreview @Composable -fun wearSettings() = WearReadmeFrame( +fun WearSettings() = WearReadmeFrame( WearDashboardState( route = WearRoute.SETTINGS, preferences = AppPreferences( @@ -60,11 +61,10 @@ fun wearSettings() = WearReadmeFrame( @PreviewTest @ReadmeWearPreview @Composable -fun wearActiveRecording() = WearReadmeFrame( +fun WearActiveRecording() = WearReadmeFrame( WearDashboardState( route = WearRoute.ACTIVE, - selectedSensorIds = setOf(1, 4), - includesGps = true, + activeSession = RecordingSessionState.Running("phone", "walk", 0L, listOf(1, 4), true, 60_000L, 1), ), ) diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png diff --git a/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png similarity index 100% rename from wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png rename to wear/src/screenshotTestDebug/reference/com/tomasrepcik/sensorbox/home/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt similarity index 51% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt index 89aef5c..0aae9c3 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/home/WearDashboardReducerTest.kt @@ -1,13 +1,28 @@ -package com.tomasrepcik.sensorbox.presentation.dashboard +package com.tomasrepcik.sensorbox.home import com.tomasrepcik.sensorbox.core.testing.AppPreferencesFixtures -import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination +import com.tomasrepcik.sensorbox.menu.WearMenuDestination import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class WearDashboardReducerTest { + @Test + fun `Given a previously viewed sensor When live preview is reopened Then the picker has no old selection`() { + // Given + val previous = WearDashboardState(route = WearRoute.LIVE, liveSensorType = 1, liveSamples = listOf(listOf(2f))) + val menu = WearDashboardReducer.reduce(previous, WearDashboardIntent.Back) + + // When + val reopened = WearDashboardReducer.reduce(menu, WearDashboardIntent.Open(WearMenuDestination.LIVE_SENSOR)) + + // Then + assertEquals(WearRoute.LIVE, reopened.route) + assertEquals(null, reopened.liveSensorType) + assertTrue(reopened.liveSamples.isEmpty()) + } + @Test fun `Given menu When record is opened Then record route is selected`() { val givenState = WearDashboardState() @@ -32,16 +47,6 @@ class WearDashboardReducerTest { assertEquals(30, actual.preferences.recording.gpsIntervalSeconds) } - @Test - fun `Given menu When sync is selected Then transfer screen is opened`() { - val actual = WearDashboardReducer.reduce( - WearDashboardState(), - WearDashboardIntent.Open(WearMenuDestination.SYNC), - ) - - assertEquals(WearRoute.SETTINGS, actual.route) - } - @Test fun `Given live picker When sensor is chosen Then chart route retains sensor`() { val givenState = WearDashboardState(route = WearRoute.LIVE) @@ -64,4 +69,33 @@ class WearDashboardReducerTest { assertEquals(WearRoute.ACTIVE, active.route) assertEquals(WearRoute.MENU, finished.route) } + + @Test + fun `Given local selections When phone recording starts Then its session is displayed`() { + // Given + val state = WearDashboardState(selectedSensorIds = setOf(1, 2, 3), includesGps = true) + val session = RecordingSessionState.Running("phone", "walk", 1L, listOf(4), false, 60_000L, 3) + + // When + val active = WearDashboardReducer.recordingSessionChanged(state, session) + + // Then + assertEquals(session, active.activeSession) + assertEquals(setOf(1, 2, 3), active.selectedSensorIds) + assertTrue(active.includesGps) + } + + @Test + fun `Given a running session When stopping Then its config remains visible`() { + // Given + val session = RecordingSessionState.Running("phone", "walk", 1L, listOf(4), false) + val active = WearDashboardReducer.recordingSessionChanged(WearDashboardState(), session) + + // When + val stopping = WearDashboardReducer.recordingSessionChanged(active, RecordingSessionState.Stopping) + + // Then + assertEquals(session, stopping.activeSession) + assertTrue(stopping.isStopping) + } } diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt similarity index 96% rename from app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt index ee8b5fa..b9f345c 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/PolicyLinksTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/PolicyLinksTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox +package com.tomasrepcik.sensorbox.menu import org.junit.Assert.assertEquals import org.junit.Test diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt similarity index 91% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt index 3cdf31b..5c66150 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/menu/WearMenuReducerTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.menu +package com.tomasrepcik.sensorbox.menu import org.junit.Assert.assertEquals import org.junit.Test diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt similarity index 58% rename from wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt index 2a8565c..f90a0b5 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/communication/WearCommandHandlerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/pairedrecording/WearCommandHandlerTest.kt @@ -1,23 +1,25 @@ -package com.tomasrepcik.sensorbox.communication +package com.tomasrepcik.sensorbox.pairedrecording -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppError +import com.tomasrepcik.sensorbox.core.failure.AppErrorCode +import com.tomasrepcik.sensorbox.core.failure.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferences -import com.tomasrepcik.sensorbox.domain.recording.WatchRecordingControlUseCase -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingRequest -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.recording.WatchRecordingControlUseCase +import com.tomasrepcik.sensorbox.sync.SyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import com.tomasrepcik.sensorbox.wearoslib.connection.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingOutcome +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingSettings +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearSensorInfo +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearStopReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest @@ -51,7 +53,7 @@ class WearCommandHandlerTest { fun `Given encoded start command When dispatched Then direct recording starts`() = runTest { // Given val fixture = Fixture() - val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { }) + val dispatcher = WearMessageDispatcher(fixture.handler) { } val payload = WearCommandCodec.encode(WearCommand.StartRecording("session-123", request())).getOrThrow() // When @@ -122,7 +124,56 @@ class WearCommandHandlerTest { assertEquals(2, fixture.recording.startCalls) } - private fun request() = WearRecordingRequest("fixture", listOf(1), includesGps = false) + @Test + fun `Given watch files When the phone checks Then counts are returned without copying`() = runTest { + // Given + val fixture = Fixture() + + // When + fixture.handler.handle(WearCommand.CheckWatchMeasurements("check")) + + // Then + assertEquals(WearCommand.WatchMeasurementsStatus("check", 1, 1), fixture.repository.commands.single()) + assertEquals(0, fixture.syncCalls) + } + + @Test + fun `Given watch files When the phone syncs Then completion counts are returned`() = runTest { + // Given + val fixture = Fixture() + + // When + fixture.handler.handle(WearCommand.CopyWatchMeasurements("copy")) + + // Then + assertEquals( + WearCommand.WatchMeasurementsStatus("copy", 1, 1, finished = true), + fixture.repository.commands.single(), + ) + assertEquals(1, fixture.syncCalls) + } + + @Test + fun `Given a transfer failure When the phone syncs Then a failure result is returned`() = runTest { + // Given + val fixture = Fixture() + fixture.syncResult = AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Send watch file")) + + // When + fixture.handler.handle(WearCommand.CopyWatchMeasurements("copy")) + + // Then + val status = fixture.repository.commands.single() as WearCommand.WatchMeasurementsStatus + assertTrue(status.failed) + assertTrue(status.finished) + } + + private fun request() = WearRecordingRequest( + "fixture", + listOf(1), + includesGps = false, + settings = WearRecordingSettings(0, stopOnLowBattery = true, useWakeLock = false, gpsIntervalSeconds = 1, gpsMinDistanceMeters = 0), + ) private fun sensor() = WearSensorInfo(1, "Accelerometer", "Fixture", 1, "sensor", 1f, 1f, 1f, 1, 1, 0, false) @@ -130,11 +181,31 @@ class WearCommandHandlerTest { val repository = CapturingRepository() val recording = FakeWearRecordingControl() val requirements = FakeWearRecordingRequirements() + var syncCalls = 0 + var syncResult: AppResult = AppResult.success(1) + private val sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)) + private val exchange = RecordingCommandExchange( + sendCommand = sendCommand, + peerCapability = "phone-capability", + peerPath = "/phone", + peerName = "phone", + ) val handler = WearCommandHandler( recording = recording, environment = requirements, - sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - phoneResults = PhoneRecordingResultInbox(), + sendCommand = sendCommand, + recordingCommands = exchange, + recordingResults = exchange, + syncMeasurements = object : SyncWatchMeasurementsUseCase { + override fun cancel(requestId: String) = Unit + + override suspend fun invoke(requestId: String): AppResult { + syncCalls += 1 + return syncResult + } + + override fun available(): AppResult> = AppResult.success(1 to 1) + }, ) } } @@ -177,7 +248,7 @@ private class CapturingRepository : WearConnectionRepository { override fun observeCapability(capability: String): Flow = emptyFlow() - override suspend fun findNode(capability: String): WearNode? = WearNode("phone", "Phone", isNearby = true) + override suspend fun findNode(capability: String): WearNode = WearNode("phone", "Phone", isNearby = true) override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { if (failSends) { diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt similarity index 94% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt index 0ee9060..1cc990c 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchReducerTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt similarity index 64% rename from wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt index 68cfa09..24bc0a2 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/phonelaunch/PhoneLaunchViewModelTest.kt @@ -1,16 +1,16 @@ -package com.tomasrepcik.sensorbox.presentation.phonelaunch +package com.tomasrepcik.sensorbox.phonelaunch -import com.tomasrepcik.sensorbox.core.error.AppFailureStore -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.failure.AppFailureStore +import com.tomasrepcik.sensorbox.core.failure.DiagnosticLogger import com.tomasrepcik.sensorbox.testing.MainDispatcherRule -import com.tomasrepcik.sensorbox.wearoslib.connectivity.FakeWearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.connection.FakeWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connection.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connection.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connection.WearNode +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearCommandCodec import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferencesTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferencesTest.kt new file mode 100644 index 0000000..7ce5e35 --- /dev/null +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/PhoneRecordingPreferencesTest.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.recording + +import com.tomasrepcik.sensorbox.core.preferences.AppPreferences +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingRequest +import com.tomasrepcik.sensorbox.wearoslib.pairedrecording.WearRecordingSettings +import org.junit.Assert.assertEquals +import org.junit.Test + +class PhoneRecordingPreferencesTest { + @Test + fun `Given different watch preferences When a phone request is applied Then phone recording settings are used`() { + // Given + val watch = AppPreferences() + val request = WearRecordingRequest( + folderName = "walk", + sensorIds = listOf(1), + includesGps = true, + settings = WearRecordingSettings(3, false, true, 5, 0), + ) + + // When + val preferences = request.recordingPreferences(watch) + + // Then + assertEquals(3, preferences.recording.sensorSamplingPeriod) + assertEquals(false, preferences.recording.stopRecordingOnLowBattery) + assertEquals(true, preferences.recording.useWakeLock) + assertEquals(5, preferences.recording.gpsIntervalSeconds) + assertEquals(0, preferences.recording.gpsMinDistanceMeters) + assertEquals(watch.display, preferences.display) + assertEquals(0, watch.recording.sensorSamplingPeriod) + } +} diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt similarity index 92% rename from wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt index 726b64a..e8754b1 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/recording/WatchRecordingPermissionUseCaseTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/WatchRecordingPermissionUseCaseTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.recording +package com.tomasrepcik.sensorbox.recording import android.Manifest import org.junit.Assert.assertEquals diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt similarity index 85% rename from wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt rename to wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt index 5d77e36..5296f42 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorTypesTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/recording/sources/WatchSensorTypesTest.kt @@ -1,4 +1,4 @@ -package com.tomasrepcik.sensorbox.domain.sensors +package com.tomasrepcik.sensorbox.recording.sources import android.hardware.Sensor import org.junit.Assert.assertFalse diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCaseTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCaseTest.kt new file mode 100644 index 0000000..b736fb1 --- /dev/null +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/sync/TransferWatchMeasurementUseCaseTest.kt @@ -0,0 +1,160 @@ +package com.tomasrepcik.sensorbox.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.recording.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileMetadata +import com.tomasrepcik.sensorbox.wearoslib.sync.WearFileTransferClient +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.InputStream + +class TransferWatchMeasurementUseCaseTest { + @get:Rule + val temporary = TemporaryFolder() + + @Test + fun `Given saved files When every acknowledgement arrives Then the watch measurement is deleted`() = runTest { + // Given + val fixture = fixture() + + // When + val result = fixture.transfer("phone", fixture.files, "request") + + // Then + assertTrue(result.isSuccess) + assertEquals(listOf("samples", "metadata"), fixture.client.received.take(2)) + assertEquals(3, fixture.client.received.size) + assertFalse(fixture.folder.exists()) + } + + @Test + fun `Given a failed file When transfer ends Then all watch originals remain`() = runTest { + // Given + val fixture = fixture() + fixture.client.failAt = 2 + + // When + val result = fixture.transfer("phone", fixture.files, "request") + + // Then + assertTrue(result.isFailure) + assertTrue(fixture.files.all(File::exists)) + } + + @Test + fun `Given a changing measurement When transfer finishes Then watch originals remain`() = runTest { + // Given + val fixture = fixture() + fixture.client.afterSend = { fixture.files.first().writeText("changed") } + + // When + val result = fixture.transfer("phone", fixture.files, "request") + + // Then + assertTrue(result.isFailure) + assertTrue(fixture.files.all(File::exists)) + } + + @Test + fun `Given recording is stopping When sync starts Then files are neither sent nor deleted`() = runTest { + // Given + val fixture = fixture() + fixture.sessions.markStopping() + + // When + val result = fixture.transfer("phone", fixture.files, "request") + + // Then + assertTrue(result.isFailure) + assertTrue(fixture.client.received.isEmpty()) + assertTrue(fixture.files.all(File::exists)) + } + + @Test + fun `Given staged files When the folder acknowledgement is lost Then watch originals remain`() = runTest { + // Given + val fixture = fixture() + fixture.client.failAt = 3 + + // When + val result = fixture.transfer("phone", fixture.files, "request") + + // Then + assertTrue(result.isFailure) + assertTrue(fixture.files.all(File::exists)) + assertEquals(3, fixture.client.received.size) + } + + @Test + fun `Given interrupted cleanup When retried Then the phone copy is not replaced`() = runTest { + // Given + val fixture = fixture() + WatchMeasurementCleanup().confirm(fixture.folder, fixture.files) + fixture.files.first().delete() + + // When + val result = fixture.transfer("phone", fixture.files.drop(1), "retry") + + // Then + assertTrue(result.isSuccess) + assertTrue(fixture.client.received.isEmpty()) + assertFalse(fixture.folder.exists()) + } + + private fun fixture(): Fixture { + val folder = temporary.newFolder("measurement") + val files = listOf( + File(folder, "sensor.csv").apply { writeText("samples") }, + File(folder, "metadata.json").apply { writeText("metadata") }, + ) + val client = SavingPhone() + val sessions = RecordingSessionStore() + return Fixture(folder, files, client, sessions, TransferWatchMeasurementUseCase(client, sessions)) + } + + private data class Fixture( + val folder: File, + val files: List, + val client: SavingPhone, + val sessions: RecordingSessionStore, + val transfer: TransferWatchMeasurementUseCase, + ) + + private class SavingPhone : WearFileTransferClient { + override fun cancel(requestId: String) = Unit + + val received = mutableListOf() + var failAt = 0 + var afterSend: () -> Unit = {} + + override suspend fun send( + nodeId: String, + metadata: WearFileMetadata, + input: () -> InputStream, + ): AppResult { + received += input().bufferedReader().use { it.readText() } + afterSend() + return if (received.size == failAt) { + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Save file on phone")) + } else { + AppResult.success(Unit) + } + } + + override suspend fun reject(channel: ChannelClient.Channel): AppResult = error("Unused") + + override suspend fun receive( + channel: ChannelClient.Channel, + consume: (InputStream) -> AppResult, + ): AppResult = error("Unused") + } +}