diff --git a/README.md b/README.md index dc5a2de..9629b45 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,125 @@ -# SensorBox - record all your sensors to csv files - -This repository is now archived and will no longer be actively maintained. - -

- -

- -

- -

- -The SensorBox provides easy way to access sensors in Android phone and Wear Os. You can customize measurements in many ways, which is suitable for development of other apps. The outputs of the app are raw outputs of the system. - -[![API](https://img.shields.io/badge/API-24%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=24) -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) - -## Features: - -Whole functionality is hidden under SensorServices library, where one foreground service operates with sensors, GPS and other APIs. - -* stores **sensor outputs** into the csv: *timestamp, values, accuracy* -* values from the sensors are in raw format - **no resampling** -* pick native sensor speed limits -* compatible with **GPS** -* can use **Activity recognition API from Android** and **Significant motion sensor** -* write custom annotation during measurement -* all extra information are stored in **JSON** -* check sensor attributes and preview of the outputs -* measurement can be customized : - * write own key words to measurement - * set up timed alarms - * set up countdown to start measurement - * stop on low battery measurement - * lock CPU, add app to whitelist -* compatible with **Wear Os** with similar features - -## Organization of code and the libraries: - -### Code -* whole code is in **Kotlin** (Flipper - third party - code is in Java) -* the phone app follows **MVI architecture** - activity/fragment -> ViewModel -> repository - * There are 2 activities created with this architecture : - * **MainActivity** - created with other fragments like HomeFragment, AdvancedFragment, SettingsFragment, ... - these **fragments share one ViewModel** defined by MainActivity. Meanwhile for the navigation is used androidx fragment navigation library. - * **MeasurementActivity** - alone activity to create annotations / stop the measurement if it is proceeding - **has its own ViewModel** -* In the phone application is used **Hilt - dependency injection library** -* **The phone app and the Wear Os app use the same SensorService Library** which covers all the requirements from the apps. The library provides intents builders for both of them. -* **WearOsLib** provides easy and comprehensive code of how to find other device and send messages, send file between them. - - -### Libraries: - -* **app / wear** - implementation for the phone / wearable respectively -* **CountDownDialog** - library for creation of the countdowns, with interaface to interact and custom Dialog -* **Flipper** - [Storage access framework](https://github.com/baldapps/Flipper) created by [baldapps](https://github.com/baldapps) -* **Sensorservices** - main background service, which registers all the sensors and other providers of the data -* **WearOsLib** - general library for communication of the phone and wearable and vice versa - -## How to build the project: - -* clone / download the project to your machine -* to activate google services follow steps at **[Firebase](https://console.firebase.google.com/u/0/).** Create project and get the `google-services.json` - * make sure, that the package id in app/wear build gradle is the same as in firebase console -* `google-services.json` copy to the app folder and wear folder too -* to activate Google maps, follow the steps here to create API key **[Google documentation](https://developers.google.com/maps/documentation/android-sdk/get-api-key#console)** -* adding line to `local.properties`: **MAPS_API_KEY=YOUR_API_KEY** is enough - - -## Third parties: - -Thanks goes to: - -* [GraphView](https://github.com/jjoe64/GraphView) - chart library -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -* [Flipper](https://github.com/baldapps/Flipper) - storage access framework -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -* [AppIntro](https://github.com/AppIntro/AppIntro) - introduction to the app for the first launch -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -* [Material dialogs](https://github.com/afollestad/material-dialogs) - dialogs with material design style -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -* [Number picker](https://github.com/ShawnLin013/NumberPicker) - create custom number pickers -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -* [Android about page](https://github.com/medyo/android-about-page) - easy way to create about page -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -* [Licenses dialog](https://github.com/PSDev/LicensesDialog) - dialog to aggreate all licences - check out for the full licenses -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -* [Toasty](https://github.com/GrenderG/Toasty) - The usual Toast, but with steroids 💪 -[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +# 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 | Measurement setup | +|:---:|:---:| +| SensorBox sensor selection with original icons | SensorBox measurement 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 folder | +|:---:|:---:|:---:| +| SensorBox Android lifecycle introduction | SensorBox battery optimization introduction | SensorBox recording-folder introduction | + +The introduction uses the original repository artwork. Privacy Policy, Terms of Use, battery optimization, and folder selection are live native actions. Folder selection remains mandatory before setup can finish. + +## Current feature set + +- Record available phone or watch sensors at Android sampling periods. +- Record foreground GPS samples alongside sensor data. +- Run measurement 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 recordings in a user-selected Storage Access Framework folder. +- Follow system/dynamic color with light, dark, and custom fallback palettes. + +## Architecture + +The UI modules use unidirectional MVI: + +`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 v2 command encoding, and Channel file transport. App policy stays in `app` and `wear`. + +Paired phone/watch recording is all-or-nothing: both sides prepare before either commits, commands are session-correlated and idempotent, timeouts use bounded retries, and rejection or timeout compensates both sides. + +## Platform and toolchain + +- Android Gradle Plugin 9.3.1 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: + +```shell +./gradlew :app:assembleDebug :wear:assembleDebug +./gradlew testDebugUnitTest detekt +./gradlew :app:lintDebug :wear:lintDebug +``` + +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 sensor test starts the real foreground measurement service, injects three accelerometer values through the emulator console, and verifies the generated CSV: + +```shell +ANDROID_HOME="$HOME/Library/Android/sdk" \ +PHONE_SERIAL=emulator-5554 \ +tools/emulator/run_phone_sensor_test.sh +``` + +The Wear sync test sends a fixture CSV through the real Wear OS Channel API and verifies its exact bytes on the phone. Use an Android 17 Google Play phone AVD and a Wear OS 7 AVD. Pair them once with Android Studio's Pairing Assistant and complete the Wear companion flow before running: + +```shell +ANDROID_HOME="$HOME/Library/Android/sdk" \ +PHONE_SERIAL=emulator-5554 \ +WEAR_SERIAL=emulator-5556 \ +tools/emulator/run_wear_sync_test.sh +``` + +The runner creates Android Studio's ADB forward/reverse bridge and fails immediately with pairing guidance when the watch reports no peer. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected Storage Access Framework directory. + +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 + +Measurements are initiated by the user, represented by an ongoing foreground-service notification, and written locally. SensorBox does not upload measurement data or include analytics/crash-reporting SDKs. + +## License + +SensorBox is licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/WearOsLib/build.gradle.kts b/WearOsLib/build.gradle.kts index 4697001..4100136 100644 --- a/WearOsLib/build.gradle.kts +++ b/WearOsLib/build.gradle.kts @@ -40,7 +40,7 @@ android { } dependencies { - implementation(project(":core")) + implementation(project(":core-common")) implementation(libs.androidx.core.ktx) implementation(libs.play.services.wearable) @@ -49,6 +49,7 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) testImplementation(libs.junit) + testFixturesImplementation(project(":core-common")) testFixturesImplementation(libs.coroutines.core) } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt index 7db9cd1..283f93a 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/WearOsConstants.kt @@ -2,7 +2,7 @@ package com.motionapps.wearoslib object WearOsConstants { const val PHONE_APP_CAPABILITY = "phone_app" - const val PHONE_MESSAGE_PATH = "/sensorbox/v1/phone" + const val PHONE_MESSAGE_PATH = "/sensorbox/v2/phone" const val WEAR_APP_CAPABILITY = "wear_app" - const val WEAR_MESSAGE_PATH = "/sensorbox/v1/wear" + const val WEAR_MESSAGE_PATH = "/sensorbox/v2/wear" } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt index 5e123db..0804039 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/GooglePlayWearConnectionRepository.kt @@ -6,6 +6,8 @@ import com.google.android.gms.wearable.CapabilityInfo import com.google.android.gms.wearable.Node import com.google.android.gms.wearable.Wearable import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.suspendAppResult import com.motionapps.sensorbox.core.error.suspendFlatMap import dagger.hilt.android.qualifiers.ApplicationContext @@ -31,7 +33,7 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext trySend(loadConnection(capability)) awaitClose { capabilityClient.removeListener(listener) } }.catch { error -> - AppError.from(AppError.Kind.CONNECTIVITY, "Observe Wear connection", error) + AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) emit(WearConnection.Disconnected) } @@ -42,13 +44,13 @@ class GooglePlayWearConnectionRepository @Inject constructor(@ApplicationContext return WearNodeSelector.select(info.nodes.map { it.toWearNode() }) } - override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result = - suspendAppResult(AppError.Kind.CONNECTIVITY, "Find Wear node") { findNode(capability) } + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult = + suspendAppResult(AppErrorCode.CONNECTIVITY, "Find Wear node") { findNode(capability) } .suspendFlatMap { node -> if (node == null) { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Find reachable Wear node for $capability")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find reachable Wear node for $capability")) } else { - suspendAppResult(AppError.Kind.CONNECTIVITY, "Send Wear message") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Send Wear message") { messageClient.sendMessage(node.id, path, payload).await() Unit } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt index f04a0cf..518a4bb 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionRepository.kt @@ -1,5 +1,6 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow interface WearConnectionRepository { @@ -7,5 +8,5 @@ interface WearConnectionRepository { suspend fun findNode(capability: String): WearNode? - suspend fun sendMessage(capability: String, path: String, payload: ByteArray): Result + suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt index d884c24..32cd61e 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/connectivity/WearConnectionUseCases.kt @@ -1,5 +1,6 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -8,9 +9,9 @@ class ObserveWearCapabilityUseCase @Inject constructor(private val repository: W } class SendWearMessageUseCase @Inject constructor(private val repository: WearConnectionRepository) { - suspend operator fun invoke(capability: String, path: String, message: String): Result = + suspend operator fun invoke(capability: String, path: String, message: String): AppResult = invoke(capability, path, message.encodeToByteArray()) - suspend operator fun invoke(capability: String, path: String, payload: ByteArray): Result = + suspend operator fun invoke(capability: String, path: String, payload: ByteArray): AppResult = repository.sendMessage(capability, path, payload) } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt index b5fef8b..4a78063 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/GooglePlayWearFileTransferClient.kt @@ -3,7 +3,8 @@ package com.motionapps.wearoslib.files import android.content.Context import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.suspendAppResult import com.motionapps.sensorbox.core.error.suspendFlatMap @@ -22,36 +23,36 @@ class GooglePlayWearFileTransferClient @Inject constructor(@ApplicationContext c nodeId: String, metadata: WearFileMetadata, input: () -> java.io.InputStream, - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { WearFilePathCodec.encode(metadata).suspendFlatMap { path -> - suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear channel") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear channel") { channelClient.openChannel(nodeId, path).await() } }.suspendFlatMap { channel -> - val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Write Wear channel") { + val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Write Wear channel") { input().use { source -> channelClient.getOutputStream(channel).await().use(source::copyTo) } } - val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { channelClient.close(channel).await() } - listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Send Wear file") - }.withAppError(AppError.Kind.CONNECTIVITY, "Send Wear file") + 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) -> Result, - ): Result = withContext(Dispatchers.IO) { - val transfer = suspendAppResult(AppError.Kind.CONNECTIVITY, "Open Wear input stream") { + consume: (java.io.InputStream) -> AppResult, + ): AppResult = withContext(Dispatchers.IO) { + val transfer = suspendAppResult(AppErrorCode.CONNECTIVITY, "Open Wear input stream") { channelClient.getInputStream(channel).await() }.suspendFlatMap { input -> input.use(consume) } - val close = suspendAppResult(AppError.Kind.CONNECTIVITY, "Close Wear channel") { + val close = suspendAppResult(AppErrorCode.CONNECTIVITY, "Close Wear channel") { channelClient.close(channel).await() } - listOf(transfer, close).combineAppResults(AppError.Kind.CONNECTIVITY, "Receive Wear file") + listOf(transfer, close).combineAppResults(AppErrorCode.CONNECTIVITY, "Receive Wear file") } } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt index 4f63275..c9bc1fb 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFilePathCodec.kt @@ -1,46 +1,89 @@ package com.motionapps.wearoslib.files import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap -import java.util.Base64 object WearFilePathCodec { - fun encode(metadata: WearFileMetadata): Result = if ( + fun encode(metadata: WearFileMetadata): AppResult = if ( metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart() ) { - appResult(AppError.Kind.CONNECTIVITY, "Encode Wear file path") { + appResult(AppErrorCode.CONNECTIVITY, "Encode Wear file path") { "$PREFIX/${metadata.measurementName.encodePart()}/${metadata.fileName.encodePart()}" } } else { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } - fun decode(path: String): Result { + fun decode(path: String): AppResult { val parts = path.removePrefix("$PREFIX/").split('/') if (!path.startsWith("$PREFIX/") || parts.size != 2) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } - return appResult(AppError.Kind.CONNECTIVITY, "Decode Wear file path") { + return appResult(AppErrorCode.CONNECTIVITY, "Decode Wear file path") { WearFileMetadata(parts[0].decodePart(), parts[1].decodePart()) }.flatMap { metadata -> if (metadata.measurementName.isSafePathPart() && metadata.fileName.isSafePathPart()) { - Result.success(metadata) + AppResult.success(metadata) } else { - Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear file path")) + AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Validate Wear file path")) } } } - private fun String.encodePart(): String = Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(encodeToByteArray()) + private fun String.encodePart(): String = encodeToByteArray().toBase64Url() - private fun String.decodePart(): String = Base64.getUrlDecoder().decode(this).decodeToString() + private fun String.decodePart(): String = decodeBase64Url().decodeToString() + + private fun ByteArray.toBase64Url(): String = buildString((size * 4 + 2) / 3) { + var index = 0 + while (index < size) { + val first = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[first ushr 2]) + if (index < size) { + val second = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[(first and 0x03) shl 4 or (second ushr 4)]) + if (index < size) { + val third = this@toBase64Url[index++].toInt() and BYTE_MASK + append(BASE64_URL_ALPHABET[(second and 0x0F) shl 2 or (third ushr 6)]) + append(BASE64_URL_ALPHABET[third and 0x3F]) + } else { + append(BASE64_URL_ALPHABET[(second and 0x0F) shl 2]) + } + } else { + append(BASE64_URL_ALPHABET[(first and 0x03) shl 4]) + } + } + } + + private fun String.decodeBase64Url(): ByteArray { + require(length % 4 != 1) { "Invalid URL-safe Base64 length" } + val output = ByteArray(length * 3 / 4) + var outputIndex = 0 + var buffer = 0 + var bitCount = 0 + for (character in this) { + val value = BASE64_URL_ALPHABET.indexOf(character) + require(value >= 0) { "Invalid URL-safe Base64 character" } + buffer = buffer shl 6 or value + bitCount += 6 + if (bitCount >= 8) { + bitCount -= 8 + output[outputIndex++] = (buffer ushr bitCount).toByte() + buffer = buffer and ((1 shl bitCount) - 1) + } + } + require(buffer == 0) { "Invalid URL-safe Base64 trailing bits" } + return output.copyOf(outputIndex) + } private fun String.isSafePathPart(): Boolean = isNotBlank() && length <= MAX_PART_LENGTH && '/' !in this && '\\' !in this && this != "." && this != ".." const val PREFIX = "/sensorbox/v1/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/motionapps/wearoslib/files/WearFileTransferClient.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt index 5dd106b..1c22dc5 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/files/WearFileTransferClient.kt @@ -1,10 +1,11 @@ package com.motionapps.wearoslib.files import com.google.android.gms.wearable.ChannelClient +import com.motionapps.sensorbox.core.error.AppResult import java.io.InputStream interface WearFileTransferClient { - suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): Result + suspend fun send(nodeId: String, metadata: WearFileMetadata, input: () -> InputStream): AppResult - suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> Result): Result + suspend fun receive(channel: ChannelClient.Channel, consume: (InputStream) -> AppResult): AppResult } diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt new file mode 100644 index 0000000..4b5ed19 --- /dev/null +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/SendWearCommandUseCase.kt @@ -0,0 +1,13 @@ +package com.motionapps.wearoslib.protocol + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import javax.inject.Inject + +class SendWearCommandUseCase @Inject constructor(private val sendMessage: SendWearMessageUseCase) { + suspend operator fun invoke(capability: String, path: String, command: WearCommand): AppResult = + WearCommandCodec.encode(command).suspendFlatMap { payload -> + sendMessage(capability, path, payload) + } +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt index c2a18cc..4fc3633 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommand.kt @@ -1,24 +1,61 @@ package com.motionapps.wearoslib.protocol +import com.motionapps.sensorbox.core.error.AppErrorCode + sealed interface WearCommand { data object LaunchPhone : WearCommand - data class StartMeasurement( - val folderName: String, - val sensorIds: List, - val includesGps: Boolean, - val startAtEpochMillis: Long = System.currentTimeMillis(), - val durationMillis: Long = 0L, - val measurementType: String = "ENDLESS", - ) : WearCommand - - data object StopMeasurement : WearCommand - data object SyncMeasurements : WearCommand data object RequestSensorList : WearCommand data class SensorList(val sensors: List) : WearCommand + + data class PrepareRecording(val sessionId: String, val request: WearRecordingRequest) : WearCommand + + data class CommitRecording(val sessionId: String, val startAtEpochMillis: Long) : WearCommand + + data class AbortRecording(val sessionId: String) : WearCommand + + data class StopRecording(val sessionId: String, val reason: WearStopReason) : WearCommand + + data class Acknowledgement( + val sessionId: String, + val command: WearSessionCommand, + val outcome: WearAcknowledgementOutcome, + val errorCode: AppErrorCode? = null, + val failureCount: Int = 0, + ) : WearCommand +} + +data class WearRecordingRequest( + val folderName: String, + val sensorIds: List, + val includesGps: Boolean, + val durationMillis: Long = 0L, + val measurementType: String = "ENDLESS", +) + +enum class WearSessionCommand { + PREPARE, + COMMIT, + ABORT, + STOP, +} + +enum class WearAcknowledgementOutcome { + SUCCEEDED, + REJECTED, + FAILED, +} + +enum class WearStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + PAIRED_ABORT, + SERVICE_DESTROYED, } data class WearSensorInfo(val type: Int, val name: String, val vendor: String, val isHeartRate: Boolean) diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt index e6311a9..0415be4 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt +++ b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearCommandCodec.kt @@ -1,6 +1,8 @@ package com.motionapps.wearoslib.protocol import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import java.io.ByteArrayInputStream @@ -9,20 +11,14 @@ import java.io.DataInputStream import java.io.DataOutputStream object WearCommandCodec { - fun encode(command: WearCommand): Result { - val itemCount = when (command) { - is WearCommand.SensorList -> command.sensors.size - is WearCommand.StartMeasurement -> command.sensorIds.size - else -> 0 - } - if (itemCount > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command")) - } - return appResult(AppError.Kind.CONNECTIVITY, "Encode Wear command") { + const val PROTOCOL_VERSION = 2 + + fun encode(command: WearCommand): AppResult = validate(command).flatMap { + appResult(AppErrorCode.CONNECTIVITY, "Encode Wear command") { ByteArrayOutputStream().use { bytes -> DataOutputStream(bytes).use { output -> output.writeInt(MAGIC) - output.writeByte(VERSION) + output.writeByte(PROTOCOL_VERSION) output.writeCommand(command) } bytes.toByteArray() @@ -30,107 +26,198 @@ object WearCommandCodec { } } - fun decode(payload: ByteArray): Result = appResult(AppError.Kind.CONNECTIVITY, "Read Wear command") { + fun decode(payload: ByteArray): AppResult = appResult( + AppErrorCode.CONNECTIVITY, + "Decode Wear protocol v2 command", + ) { DataInputStream(ByteArrayInputStream(payload)).use { input -> - val validHeader = input.readInt() == MAGIC && input.readUnsignedByte() == VERSION - if (validHeader) { - input.readCommand() - } else { - Result.failure( - AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command header"), - ) - } + require(input.readInt() == MAGIC) { "Unsupported Wear protocol magic" } + require(input.readUnsignedByte() == PROTOCOL_VERSION) { "Unsupported Wear protocol version" } + input.readCommand().also { require(input.available() == 0) { "Trailing Wear command bytes" } } } - }.flatMap { it } + }.flatMap { command -> validate(command).map { command } } + + private fun validate(command: WearCommand): AppResult = if (command.isValid()) { + AppResult.success(Unit) + } else { + AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear protocol v2 command")) + } private fun DataOutputStream.writeCommand(command: WearCommand) { when (command) { WearCommand.LaunchPhone -> writeByte(TYPE_LAUNCH_PHONE) - is WearCommand.StartMeasurement -> writeMeasurement(command) - WearCommand.StopMeasurement -> writeByte(TYPE_STOP_MEASUREMENT) + WearCommand.SyncMeasurements -> writeByte(TYPE_SYNC_MEASUREMENTS) + WearCommand.RequestSensorList -> writeByte(TYPE_REQUEST_SENSOR_LIST) + is WearCommand.SensorList -> writeSensorList(command) + + is WearCommand.PrepareRecording -> writePrepare(command) + + is WearCommand.CommitRecording -> { + writeByte(TYPE_COMMIT_RECORDING) + writeUTF(command.sessionId) + writeLong(command.startAtEpochMillis) + } + + is WearCommand.AbortRecording -> { + writeByte(TYPE_ABORT_RECORDING) + writeUTF(command.sessionId) + } + + is WearCommand.StopRecording -> { + writeByte(TYPE_STOP_RECORDING) + writeUTF(command.sessionId) + writeUTF(command.reason.name) + } + + is WearCommand.Acknowledgement -> writeAcknowledgement(command) } } + private fun DataOutputStream.writePrepare(command: WearCommand.PrepareRecording) { + writeByte(TYPE_PREPARE_RECORDING) + writeUTF(command.sessionId) + writeUTF(command.request.folderName) + writeBoolean(command.request.includesGps) + writeByte(command.request.sensorIds.size) + command.request.sensorIds.forEach(::writeInt) + writeLong(command.request.durationMillis) + writeUTF(command.request.measurementType) + } + + private fun DataOutputStream.writeAcknowledgement(command: WearCommand.Acknowledgement) { + writeByte(TYPE_ACKNOWLEDGEMENT) + writeUTF(command.sessionId) + writeUTF(command.command.name) + writeUTF(command.outcome.name) + writeBoolean(command.errorCode != null) + command.errorCode?.let { writeUTF(it.name) } + writeInt(command.failureCount) + } + private fun DataOutputStream.writeSensorList(command: WearCommand.SensorList) { writeByte(TYPE_SENSOR_LIST) writeByte(command.sensors.size) command.sensors.forEach { sensor -> writeInt(sensor.type) - writeUTF(sensor.name.take(MAX_SENSOR_TEXT_LENGTH)) - writeUTF(sensor.vendor.take(MAX_SENSOR_TEXT_LENGTH)) + writeUTF(sensor.name) + writeUTF(sensor.vendor) writeBoolean(sensor.isHeartRate) } } - private fun DataOutputStream.writeMeasurement(command: WearCommand.StartMeasurement) { - writeByte(TYPE_START_MEASUREMENT) - writeUTF(command.folderName.take(MAX_FOLDER_LENGTH)) - writeBoolean(command.includesGps) - writeByte(command.sensorIds.size) - command.sensorIds.forEach(::writeInt) - writeLong(command.startAtEpochMillis) - writeLong(command.durationMillis) - writeUTF(command.measurementType.take(MAX_TYPE_LENGTH)) - } - - private fun DataInputStream.readCommand(): Result = when (readUnsignedByte()) { - TYPE_LAUNCH_PHONE -> Result.success(WearCommand.LaunchPhone) - TYPE_START_MEASUREMENT -> readMeasurement() - TYPE_STOP_MEASUREMENT -> Result.success(WearCommand.StopMeasurement) - TYPE_SYNC_MEASUREMENTS -> Result.success(WearCommand.SyncMeasurements) - TYPE_REQUEST_SENSOR_LIST -> Result.success(WearCommand.RequestSensorList) + private fun DataInputStream.readCommand(): WearCommand = when (readUnsignedByte()) { + TYPE_LAUNCH_PHONE -> WearCommand.LaunchPhone + TYPE_SYNC_MEASUREMENTS -> WearCommand.SyncMeasurements + TYPE_REQUEST_SENSOR_LIST -> WearCommand.RequestSensorList TYPE_SENSOR_LIST -> readSensorList() - else -> Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear command type")) + TYPE_PREPARE_RECORDING -> readPrepare() + TYPE_COMMIT_RECORDING -> WearCommand.CommitRecording(readUTF(), readLong()) + TYPE_ABORT_RECORDING -> WearCommand.AbortRecording(readUTF()) + TYPE_STOP_RECORDING -> WearCommand.StopRecording(readUTF(), readNamedEnum(WearStopReason.entries)) + TYPE_ACKNOWLEDGEMENT -> readAcknowledgement() + else -> error("Unknown Wear command type") } - private fun DataInputStream.readSensorList(): Result { - val count = readUnsignedByte() - if (count > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) - } - return Result.success( - WearCommand.SensorList( - List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, - ), - ) - } - - private fun DataInputStream.readMeasurement(): Result { + private fun DataInputStream.readPrepare(): WearCommand.PrepareRecording { + val sessionId = readUTF() val folderName = readUTF() val includesGps = readBoolean() val sensorCount = readUnsignedByte() - if (sensorCount > MAX_SENSORS) { - return Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Validate Wear sensor count")) - } + require(sensorCount <= MAX_SENSORS) { "Too many Wear sensors" } val sensorIds = List(sensorCount) { readInt() } - val startAt = if (available() >= Long.SIZE_BYTES) readLong() else System.currentTimeMillis() - val duration = if (available() >= Long.SIZE_BYTES) readLong() else 0L - val type = if (available() > 0) readUTF() else "ENDLESS" - return Result.success( - WearCommand.StartMeasurement( + return WearCommand.PrepareRecording( + sessionId = sessionId, + request = WearRecordingRequest( folderName = folderName, sensorIds = sensorIds, includesGps = includesGps, - startAtEpochMillis = startAt, - durationMillis = duration, - measurementType = type, + durationMillis = readLong(), + measurementType = readUTF(), ), ) } - private const val MAGIC = 0x53425831 - private const val VERSION = 1 + private fun DataInputStream.readAcknowledgement(): WearCommand.Acknowledgement { + val sessionId = readUTF() + val command = readNamedEnum(WearSessionCommand.entries) + val outcome = readNamedEnum(WearAcknowledgementOutcome.entries) + val errorCode = if (readBoolean()) readNamedEnum(AppErrorCode.entries) else null + return WearCommand.Acknowledgement(sessionId, command, outcome, errorCode, readInt()) + } + + private fun DataInputStream.readSensorList(): WearCommand.SensorList { + val count = readUnsignedByte() + require(count <= MAX_SENSORS) { "Too many Wear sensors" } + return WearCommand.SensorList( + List(count) { WearSensorInfo(readInt(), readUTF(), readUTF(), readBoolean()) }, + ) + } + + private const val MAGIC = 0x53425832 private const val TYPE_LAUNCH_PHONE = 1 - private const val TYPE_START_MEASUREMENT = 2 - private const val TYPE_STOP_MEASUREMENT = 3 - private const val TYPE_SYNC_MEASUREMENTS = 4 - private const val TYPE_REQUEST_SENSOR_LIST = 5 - private const val TYPE_SENSOR_LIST = 6 - private const val MAX_SENSORS = 64 - private const val MAX_FOLDER_LENGTH = 100 - private const val MAX_TYPE_LENGTH = 32 - private const val MAX_SENSOR_TEXT_LENGTH = 100 + private const val TYPE_SYNC_MEASUREMENTS = 2 + private const val TYPE_REQUEST_SENSOR_LIST = 3 + private const val TYPE_SENSOR_LIST = 4 + private const val TYPE_PREPARE_RECORDING = 10 + private const val TYPE_COMMIT_RECORDING = 11 + private const val TYPE_ABORT_RECORDING = 12 + private const val TYPE_STOP_RECORDING = 13 + private const val TYPE_ACKNOWLEDGEMENT = 14 +} + +private const val MAX_SENSORS = 64 +private const val MAX_FOLDER_LENGTH = 100 +private const val MAX_TYPE_LENGTH = 32 +private const val MAX_SENSOR_TEXT_LENGTH = 100 +private const val MAX_SESSION_ID_LENGTH = 128 + +private fun WearCommand.isValid(): Boolean = when (this) { + WearCommand.LaunchPhone, + WearCommand.RequestSensorList, + WearCommand.SyncMeasurements, + -> true + + is WearCommand.SensorList -> sensors.size <= MAX_SENSORS && sensors.all(WearSensorInfo::isValid) + + is WearCommand.PrepareRecording -> isValid() + + is WearCommand.CommitRecording -> validSessionId(sessionId) && startAtEpochMillis >= 0L + + is WearCommand.AbortRecording -> validSessionId(sessionId) + + is WearCommand.StopRecording -> validSessionId(sessionId) + + is WearCommand.Acknowledgement -> isValid() +} + +private fun WearSensorInfo.isValid(): Boolean = + name.length <= MAX_SENSOR_TEXT_LENGTH && vendor.length <= MAX_SENSOR_TEXT_LENGTH + +private fun WearCommand.PrepareRecording.isValid(): Boolean = validSessionId(sessionId) && + request.folderName.isNotBlank() && + request.folderName.length <= MAX_FOLDER_LENGTH && + request.sensorIds.size <= MAX_SENSORS && + request.durationMillis >= 0L && + request.measurementType.length <= MAX_TYPE_LENGTH + +private fun WearCommand.Acknowledgement.isValid(): Boolean = validSessionId(sessionId) && + failureCount >= 0 && + when (outcome) { + WearAcknowledgementOutcome.SUCCEEDED -> errorCode == null && failureCount == 0 + + WearAcknowledgementOutcome.REJECTED, + WearAcknowledgementOutcome.FAILED, + -> errorCode != null + } + +private fun validSessionId(sessionId: String): Boolean = sessionId.isNotBlank() && + sessionId.length <= MAX_SESSION_ID_LENGTH && + sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' } + +private inline fun > DataInputStream.readNamedEnum(values: List): T { + val name = readUTF() + return values.firstOrNull { it.name == name } ?: error("Unknown Wear protocol enum value") } diff --git a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt index e465931..7c807d3 100644 --- a/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt +++ b/WearOsLib/src/test/java/com/motionapps/wearoslib/protocol/WearCommandCodecTest.kt @@ -1,48 +1,79 @@ package com.motionapps.wearoslib.protocol +import com.motionapps.sensorbox.core.error.AppErrorCode import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class WearCommandCodecTest { @Test - fun `Given a measurement command When encoded and decoded Then all fields survive`() { - val given = WearCommand.StartMeasurement("session", listOf(1, 4, 21), includesGps = true) - - val actual = WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()) + fun `Given protocol v2 commands When round tripped Then every field survives`() { + val request = WearRecordingRequest( + folderName = "shared_session", + sensorIds = listOf(1, 4, 21), + includesGps = true, + durationMillis = 45_000L, + measurementType = "TIMED", + ) + val commands = listOf( + WearCommand.LaunchPhone, + WearCommand.SyncMeasurements, + WearCommand.RequestSensorList, + WearCommand.SensorList(listOf(WearSensorInfo(21, "Heart rate", "Fixture", isHeartRate = true))), + WearCommand.PrepareRecording("session-123", request), + WearCommand.CommitRecording("session-123", 1_800_000_000_000L), + WearCommand.AbortRecording("session-123"), + WearCommand.StopRecording("session-123", WearStopReason.LOW_BATTERY), + WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.PREPARE, + outcome = WearAcknowledgementOutcome.SUCCEEDED, + ), + WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.STOP, + outcome = WearAcknowledgementOutcome.FAILED, + errorCode = AppErrorCode.MEASUREMENT, + failureCount = 2, + ), + ) - assertEquals(given, actual.getOrThrow()) + commands.forEach { command -> + val payload = WearCommandCodec.encode(command).getOrThrow() + assertEquals(command, WearCommandCodec.decode(payload).getOrThrow()) + } } @Test - fun `Given an unknown payload When decoded Then it is rejected`() { - val invalidPayload = byteArrayOf(1, 2, 3) + fun `Given a protocol v1 header When decoded Then it is rejected`() { + val v1Payload = byteArrayOf(0x53, 0x42, 0x58, 0x31, 0x01, 0x01) - val actual = WearCommandCodec.decode(invalidPayload) + assertTrue(WearCommandCodec.decode(v1Payload).isFailure) + } - assertTrue(actual.isFailure) + @Test + fun `Given trailing bytes When decoded Then payload is rejected`() { + val valid = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() + + assertTrue(WearCommandCodec.decode(valid + byteArrayOf(99)).isFailure) } @Test - fun `Given a synchronized measurement When encoded Then schedule survives`() { - val given = WearCommand.StartMeasurement( - folderName = "shared_session", - sensorIds = listOf(1, 21), - includesGps = false, - startAtEpochMillis = 1_800_000_000_000L, - durationMillis = 45_000L, - measurementType = "TIMED", + fun `Given a successful acknowledgement with an error When encoded Then it is rejected`() { + val invalid = WearCommand.Acknowledgement( + sessionId = "session-123", + command = WearSessionCommand.COMMIT, + outcome = WearAcknowledgementOutcome.SUCCEEDED, + errorCode = AppErrorCode.MEASUREMENT, ) - assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + assertTrue(WearCommandCodec.encode(invalid).isFailure) } @Test - fun `Given a Wear sensor catalogue When encoded and decoded Then descriptors survive`() { - val given = WearCommand.SensorList( - listOf(WearSensorInfo(21, "Heart rate", "Fixture", isHeartRate = true)), - ) + fun `Given a malformed session identifier When encoded Then it is rejected`() { + val invalid = WearCommand.AbortRecording("session/with/private/path") - assertEquals(given, WearCommandCodec.decode(WearCommandCodec.encode(given).getOrThrow()).getOrThrow()) + assertTrue(WearCommandCodec.encode(invalid).isFailure) } } diff --git a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt index 6a2025e..bfb62f3 100644 --- a/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt +++ b/WearOsLib/src/testFixtures/java/com/motionapps/wearoslib/connectivity/FakeWearConnectionRepository.kt @@ -1,5 +1,8 @@ package com.motionapps.wearoslib.connectivity +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.AppError + import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -7,7 +10,7 @@ class FakeWearConnectionRepository( initialConnection: WearConnection = WearConnection.Disconnected, ) : WearConnectionRepository { private val connection = MutableStateFlow(initialConnection) - private var sendResult: Result = Result.success(Unit) + private var sendResult: AppResult = AppResult.success(Unit) val sentMessages = mutableListOf() @@ -20,7 +23,7 @@ class FakeWearConnectionRepository( capability: String, path: String, payload: ByteArray, - ): Result { + ): AppResult { sentMessages += SentWearMessage(capability, path, payload.copyOf()) return sendResult } @@ -29,8 +32,8 @@ class FakeWearConnectionRepository( connection.value = newConnection } - fun failSending(error: Throwable) { - sendResult = Result.failure(error) + fun failSending(error: AppError) { + sendResult = AppResult.failure(error) } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24254d1..5e8b7de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,9 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) testImplementation(libs.junit) + testImplementation(libs.coroutines.test) + testImplementation(testFixtures(project(":core"))) + testImplementation(testFixtures(project(":wearoslib"))) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(testFixtures(project(":wearoslib"))) diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt index 683ca73..89cc619 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt @@ -8,12 +8,11 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorbox.ui.theme.SensorBoxTheme class MeasurementSetupScreenRobot(private val rule: ComposeContentTestRule) { - fun givenMeasurementSetup(onIntent: (MainIntent) -> Unit = {}) = apply { + fun givenMeasurementSetup(onIntent: (RecordingIntent) -> Unit = {}) = apply { rule.setContent { SensorBoxTheme { MeasurementSetupScreen( - state = MainState( - route = MainRoute.SETUP, + state = RecordingState( sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), selectedSensorIds = setOf(1), storagePath = "Fixture/SensorBox", diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt index 73cfb4c..d020340 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt @@ -11,14 +11,14 @@ class MeasurementSetupScreenTest { @Test fun givenConfiguredSetupWhenStartIsTappedThenMeasurementIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null MeasurementSetupScreenRobot(composeRule) .givenMeasurementSetup { actualIntent = it } .thenFolderAndSettingsAreVisible() .whenStartMeasurementIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.StartMeasurement, actualIntent) + assertEquals(RecordingIntent.StartMeasurement, actualIntent) } } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt index bb51b6b..1154cba 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt @@ -14,20 +14,22 @@ import androidx.compose.ui.test.performClick import com.motionapps.sensorbox.ui.theme.SensorBoxTheme class OnboardingScreenRobot(private val rule: ComposeContentTestRule) { - var lastIntent: MainIntent? = null + var lastIntent: OnboardingIntent? = null private set fun givenInteractiveOnboarding(page: Int = 0, storagePath: String? = null) = apply { rule.setContent { var state by remember { - mutableStateOf( - MainState(route = MainRoute.ONBOARDING, onboardingPage = page, storagePath = storagePath), - ) + mutableStateOf(OnboardingState(page = page, storagePath = storagePath)) } SensorBoxTheme { OnboardingScreen(state) { intent -> lastIntent = intent - state = MainReducer.reduce(state, intent).state + state = when (intent) { + OnboardingIntent.AdvanceOnboarding -> state.copy(page = state.page + 1) + OnboardingIntent.RetreatOnboarding -> state.copy(page = (state.page - 1).coerceAtLeast(0)) + else -> state + } } } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt index e17878d..89a074a 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt @@ -16,15 +16,15 @@ class OnboardingScreenTest { robot.thenPageIsVisible("Welcome to SensorBox").whenNextIsTapped() robot.thenPageIsVisible("Nothing is going out").whenNextIsTapped() robot.thenPageIsVisible("Privacy and terms").whenPrivacyPolicyIsTapped() - assertEquals(MainIntent.OpenPrivacyPolicy, robot.lastIntent) + assertEquals(OnboardingIntent.OpenPrivacyPolicy, robot.lastIntent) robot.whenTermsOfUseIsTapped() - assertEquals(MainIntent.OpenTermsOfUse, robot.lastIntent) + assertEquals(OnboardingIntent.OpenTermsOfUse, robot.lastIntent) robot.whenNextIsTapped().thenPageIsVisible("Android may pause recordings").whenNextIsTapped() robot.thenPageIsVisible("Allow reliable background work").whenBatterySettingsIsTapped() - assertEquals(MainIntent.RequestBatteryOptimizationExemption, robot.lastIntent) + assertEquals(OnboardingIntent.RequestBatteryOptimizationExemption, robot.lastIntent) robot.whenNextIsTapped().thenPageIsVisible("Choose a recording folder") robot.thenFinishIsDisabled().whenChooseFolderIsTapped() - assertEquals(MainIntent.ChooseStorage, robot.lastIntent) + assertEquals(OnboardingIntent.ChooseStorage, robot.lastIntent) } @Test @@ -36,6 +36,6 @@ class OnboardingScreenTest { robot.thenFinishIsEnabled().whenFinishIsTapped() - assertEquals(MainIntent.CompleteOnboarding, robot.lastIntent) + assertEquals(OnboardingIntent.CompleteOnboarding, robot.lastIntent) } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt index dd93231..342f7ed 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt @@ -12,13 +12,12 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { fun givenRecordScreen( selected: Boolean = false, gpsSelected: Boolean = false, - onIntent: (MainIntent) -> Unit = {}, + onIntent: (RecordingIntent) -> Unit = {}, ) = apply { rule.setContent { SensorBoxTheme { RecordScreen( - state = MainState( - route = MainRoute.RECORD, + state = RecordingState( sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), selectedSensorIds = if (selected) setOf(1) else emptySet(), includesGps = gpsSelected, diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt index 36755f2..f9bc39c 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt @@ -11,62 +11,62 @@ class RecordScreenTest { @Test fun givenRecordScreenWhenSensorIsTappedThenToggleIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .thenRecordingActionIsVisible() .whenAccelerometerIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.ToggleSensor(1), actualIntent) + assertEquals(RecordingIntent.ToggleSensor(1), actualIntent) } } @Test fun givenRecordScreenWhenSensorInfoIsTappedThenDetailsIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenAccelerometerInfoIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenSensorDetails(1), actualIntent) + assertEquals(RecordingIntent.OpenSensorDetails(1), actualIntent) } } @Test fun givenSelectedSensorWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(selected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenMeasurementSetup, actualIntent) + assertEquals(RecordingIntent.OpenMeasurementSetup, actualIntent) } } @Test fun givenRecordScreenWhenGpsIsTappedThenToggleGpsIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenGpsIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.ToggleGps, actualIntent) + assertEquals(RecordingIntent.ToggleGps, actualIntent) } } @Test fun givenOnlyGpsSelectedWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(gpsSelected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenMeasurementSetup, actualIntent) + assertEquals(RecordingIntent.OpenMeasurementSetup, actualIntent) } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b3724c8..d3d085b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,7 +61,7 @@ diff --git a/app/src/main/java/com/motionapps/sensorbox/MsgListener.kt b/app/src/main/java/com/motionapps/sensorbox/MsgListener.kt index 92d9083..d9ed4df 100644 --- a/app/src/main/java/com/motionapps/sensorbox/MsgListener.kt +++ b/app/src/main/java/com/motionapps/sensorbox/MsgListener.kt @@ -1,17 +1,10 @@ package com.motionapps.sensorbox -import android.content.Intent import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService -import com.motionapps.sensorbox.activities.MainActivity -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.domain.paired.PhoneWearMessageDispatcher import com.motionapps.sensorbox.domain.sync.ReceiveWearFileUseCase -import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec -import com.motionapps.wearoslib.protocol.WearSensorCatalogStore import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,29 +16,15 @@ import javax.inject.Inject @AndroidEntryPoint class MsgListener : WearableListenerService() { @Inject - lateinit var receiveWearFile: ReceiveWearFileUseCase + lateinit var messageDispatcher: PhoneWearMessageDispatcher @Inject - lateinit var wearSensorCatalog: WearSensorCatalogStore + lateinit var receiveWearFile: ReceiveWearFileUseCase private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onMessageReceived(messageEvent: MessageEvent) { - if (messageEvent.path != PHONE_MESSAGE_PATH) return - when (val command = WearCommandCodec.decode(messageEvent.data).getOrNull()) { - WearCommand.LaunchPhone -> { - val launchIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - appResult(AppError.Kind.EXTERNAL_ACTION, "Launch phone app from Wear") { - startActivity(launchIntent) - } - } - - is WearCommand.SensorList -> wearSensorCatalog.update(command.sensors) - - else -> Unit - } + serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) } } override fun onChannelOpened(channel: ChannelClient.Channel) { diff --git a/app/src/main/java/com/motionapps/sensorbox/SensorBoxApp.kt b/app/src/main/java/com/motionapps/sensorbox/SensorBoxApp.kt index 7ff52e9..d36b407 100644 --- a/app/src/main/java/com/motionapps/sensorbox/SensorBoxApp.kt +++ b/app/src/main/java/com/motionapps/sensorbox/SensorBoxApp.kt @@ -1,8 +1,10 @@ package com.motionapps.sensorbox import android.app.Application -import com.motionapps.sensorbox.core.error.AppDiagnostics +import com.motionapps.sensorbox.core.error.FileDiagnostics +import com.motionapps.sensorbox.domain.paired.PhoneRecordingSessionObserver import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject /** * building block for the Hilt dependency injection framework @@ -10,8 +12,15 @@ import dagger.hilt.android.HiltAndroidApp */ @HiltAndroidApp class SensorBoxApp : Application() { + @Inject + lateinit var diagnostics: FileDiagnostics + + @Inject + lateinit var recordingSessionObserver: PhoneRecordingSessionObserver + override fun onCreate() { super.onCreate() - AppDiagnostics.install(this) + diagnostics.installUncaughtExceptionHandler() + recordingSessionObserver.start() } } diff --git a/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt b/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt index 3218fc8..13c8c47 100644 --- a/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt +++ b/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorbox.activities import android.annotation.SuppressLint import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent import android.net.Uri import android.os.Bundle @@ -16,60 +17,116 @@ import androidx.compose.runtime.getValue import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.motionapps.sensorbox.R -import com.motionapps.sensorbox.core.error.AppDiagnostics -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.presentation.main.MainEffect import com.motionapps.sensorbox.presentation.main.MainViewModel +import com.motionapps.sensorbox.presentation.main.OnboardingEffect +import com.motionapps.sensorbox.presentation.main.OnboardingViewModel +import com.motionapps.sensorbox.presentation.main.RecordingEffect +import com.motionapps.sensorbox.presentation.main.RecordingViewModel import com.motionapps.sensorbox.presentation.main.SensorBoxApp +import com.motionapps.sensorbox.presentation.main.SettingsEffect +import com.motionapps.sensorbox.presentation.main.SettingsViewModel import com.motionapps.sensorbox.ui.theme.SensorBoxTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() + @Inject + lateinit var diagnosticsStore: DiagnosticsStore + + private val mainViewModel: MainViewModel by viewModels() + private val onboardingViewModel: OnboardingViewModel by viewModels() + private val recordingViewModel: RecordingViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + private var storageRequestOwner = StorageRequestOwner.RECORDING private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - viewModel.handleStorageResult(it.data) + when (storageRequestOwner) { + StorageRequestOwner.ONBOARDING -> onboardingViewModel.handleStorageResult(it.data) + StorageRequestOwner.RECORDING -> recordingViewModel.handleStorageResult(it.data) + } } private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - viewModel.handlePermissionResult() + recordingViewModel.handlePermissionResult() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) viewModel.showPrivacyRationale() + if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) mainViewModel.showPrivacyRationale() setContent { - val state by viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(viewModel) { viewModel.effects.collect(::handleEffect) } + val mainState by mainViewModel.state.collectAsStateWithLifecycle() + val onboardingState by onboardingViewModel.state.collectAsStateWithLifecycle() + val recordingState by recordingViewModel.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) + } SensorBoxTheme { - SensorBoxApp(state = state, onIntent = viewModel::accept) + SensorBoxApp( + mainState = mainState, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onNavigate = mainViewModel::navigate, + onOnboardingIntent = onboardingViewModel::accept, + onRecordingIntent = recordingViewModel::accept, + onSettingsIntent = settingsViewModel::accept, + ) } } } - private fun handleEffect(effect: MainEffect) { + private fun handleOnboardingEffect(effect: OnboardingEffect) { when (effect) { - MainEffect.PickStorageDirectory -> appResult(AppError.Kind.EXTERNAL_ACTION, "Open storage picker") { - directoryPicker.launch(storageIntent()) - } - - MainEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) - - MainEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) - - MainEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + OnboardingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.ONBOARDING) + OnboardingEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) + OnboardingEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) + OnboardingEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + is OnboardingEffect.Navigate -> mainViewModel.navigate(effect.route) + } + } - MainEffect.ShareDiagnosticsText -> shareDiagnosticsText() + private fun handleRecordingEffect(effect: RecordingEffect) { + when (effect) { + RecordingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.RECORDING) - MainEffect.ShareDiagnosticsFile -> shareDiagnosticsFile() + is RecordingEffect.Navigate -> mainViewModel.navigate(effect.route) - is MainEffect.RequestPermissions -> appResult(AppError.Kind.PERMISSION, "Request app permissions") { + is RecordingEffect.RequestPermissions -> appResult(AppErrorCode.PERMISSION, "Request app permissions") { permissionRequest.launch(effect.permissions.toTypedArray()) } } } + private fun handleSettingsEffect(effect: SettingsEffect) { + when (effect) { + SettingsEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText() + SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile() + is SettingsEffect.CopyDiagnosticsText -> copyDiagnostics(effect.text) + SettingsEffect.DiagnosticsCleared -> showToast(R.string.diagnostics_cleared) + is SettingsEffect.DiagnosticsFailed -> showToast(R.string.diagnostics_action_failed) + is SettingsEffect.Navigate -> mainViewModel.navigate(effect.route) + } + } + + private fun openStoragePicker(owner: StorageRequestOwner) { + storageRequestOwner = owner + appResult(AppErrorCode.EXTERNAL_ACTION, "Open storage picker") { + directoryPicker.launch(storageIntent()) + } + } + private fun openWebPage(url: String) { launchExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "Open web page") } @@ -89,7 +146,7 @@ class MainActivity : ComponentActivity() { } private fun shareDiagnosticsText() { - AppDiagnostics.readText().fold( + diagnosticsStore.readText().fold( onSuccess = { diagnostics -> val intent = Intent(Intent.ACTION_SEND) .setType("text/plain") @@ -102,9 +159,9 @@ class MainActivity : ComponentActivity() { } private fun shareDiagnosticsFile() { - AppDiagnostics.exportFile().fold( + diagnosticsStore.exportFile().fold( onSuccess = { file -> - appResult(AppError.Kind.EXTERNAL_ACTION, "Prepare diagnostics file") { + appResult(AppErrorCode.EXTERNAL_ACTION, "Prepare diagnostics file") { val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) val intent = Intent(Intent.ACTION_SEND) .setType("text/plain") @@ -119,18 +176,33 @@ class MainActivity : ComponentActivity() { ) } - private fun launchShareIntent(intent: Intent): Result = + private fun launchShareIntent(intent: Intent): AppResult = launchExternalIntent(Intent.createChooser(intent, getString(R.string.diagnostics_share)), "Share diagnostics") - private fun launchExternalIntent(intent: Intent, operation: String): Result = appResult( - AppError.Kind.EXTERNAL_ACTION, + private fun launchExternalIntent(intent: Intent, operation: String): AppResult = appResult( + AppErrorCode.EXTERNAL_ACTION, operation, ) { startActivity(intent) } private fun showDiagnosticsShareFailure() { - Toast.makeText(this, R.string.diagnostics_share_failed, Toast.LENGTH_LONG).show() + showToast(R.string.diagnostics_share_failed) + } + + 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 = { showToast(R.string.diagnostics_action_failed) }, + ) + } + + private fun showToast(message: Int) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() } private fun storageIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { @@ -138,4 +210,9 @@ class MainActivity : ComponentActivity() { addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } + + private enum class StorageRequestOwner { + ONBOARDING, + RECORDING, + } } diff --git a/app/src/main/java/com/motionapps/sensorbox/di/CoroutineModule.kt b/app/src/main/java/com/motionapps/sensorbox/di/CoroutineModule.kt new file mode 100644 index 0000000..f1a2cd0 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/di/CoroutineModule.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineModule { + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt b/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt new file mode 100644 index 0000000..5021b69 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt @@ -0,0 +1,47 @@ +package com.motionapps.sensorbox.di + +import android.app.Application +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.DiagnosticMetadata +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.error.FileDiagnostics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DiagnosticsModule { + @Provides + @Singleton + fun provideFileDiagnostics(@ApplicationContext context: Context): FileDiagnostics = FileDiagnostics( + context = context, + metadata = DiagnosticMetadata( + appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty(), + buildType = if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + "debug" + } else { + "release" + }, + deviceModel = Build.MODEL, + androidVersion = Build.VERSION.RELEASE, + processName = if (Build.VERSION.SDK_INT >= 28) { + Application.getProcessName() + } else { + context.applicationInfo.processName + }, + ), + ) + + @Provides + fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics + + @Provides + fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics +} diff --git a/app/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt b/app/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt new file mode 100644 index 0000000..64ed633 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt @@ -0,0 +1,34 @@ +package com.motionapps.sensorbox.di + +import com.motionapps.sensorbox.domain.measurement.AndroidPhoneRecordingController +import com.motionapps.sensorbox.domain.measurement.AndroidRecordingWorkflowGateway +import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway +import com.motionapps.sensorbox.domain.measurement.DocumentStorageUseCase +import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController +import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.motionapps.sensorbox.domain.paired.PhoneWearCommandHandler +import com.motionapps.sensorbox.domain.paired.PhoneWearCommandPolicy +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class RecordingModule { + @Binds + abstract fun bindPhoneRecordingController( + implementation: AndroidPhoneRecordingController, + ): PhoneRecordingController + + @Binds + abstract fun bindRecordingWorkflowGateway( + implementation: AndroidRecordingWorkflowGateway, + ): RecordingWorkflowGateway + + @Binds + abstract fun bindDocumentStorageGateway(implementation: DocumentStorageUseCase): DocumentStorageGateway + + @Binds + abstract fun bindPhoneWearCommandPolicy(implementation: PhoneWearCommandHandler): PhoneWearCommandPolicy +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCase.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCase.kt index 43e0da3..dfb35f7 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCase.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCase.kt @@ -2,16 +2,26 @@ package com.motionapps.sensorbox.domain.measurement import android.content.Context import android.content.Intent +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.storage.NativeDocumentStorage import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -class DocumentStorageUseCase @Inject constructor(@ApplicationContext private val context: Context) { - fun hasStorage(): Result = NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY) +interface DocumentStorageGateway { + fun hasStorage(): AppResult - fun displayPath(): Result = NativeDocumentStorage.displayPath(context, APP_DIRECTORY) + fun displayPath(): AppResult - fun persist(resultIntent: Intent): Result = + fun persist(resultIntent: Intent): AppResult +} + +class DocumentStorageUseCase @Inject constructor(@ApplicationContext private val context: Context) : + DocumentStorageGateway { + override fun hasStorage(): AppResult = NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY) + + override fun displayPath(): AppResult = NativeDocumentStorage.displayPath(context, APP_DIRECTORY) + + override fun persist(resultIntent: Intent): AppResult = NativeDocumentStorage.persistRootAccess(context, resultIntent, APP_DIRECTORY) private companion object { diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementControlUseCase.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementControlUseCase.kt index 5b0f52a..c548fb9 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementControlUseCase.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/MeasurementControlUseCase.kt @@ -1,114 +1,18 @@ package com.motionapps.sensorbox.domain.measurement -import android.content.Context -import android.content.Intent -import android.hardware.SensorManager -import androidx.core.content.ContextCompat -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.appResult -import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.core.error.suspendFlatMap -import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorbox.core.storage.NativeDocumentStorage -import com.motionapps.sensorservices.intent.MeasurementIntentFactory -import com.motionapps.sensorservices.intent.MeasurementLaunchRequest -import com.motionapps.sensorservices.services.MeasurementService -import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec -import dagger.hilt.android.qualifiers.ApplicationContext +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.domain.paired.PairedRecordingCoordinator +import com.motionapps.wearoslib.protocol.WearStopReason import javax.inject.Inject class MeasurementControlUseCase @Inject constructor( - @ApplicationContext private val context: Context, - private val intentFactory: MeasurementIntentFactory, - private val sendWearMessage: SendWearMessageUseCase, + private val pairedRecordingCoordinator: PairedRecordingCoordinator, + private val localController: PhoneRecordingController, ) { - suspend fun start(request: MeasurementRequest): Result = - NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY).suspendFlatMap { storageReady -> - if (!storageReady) { - return@suspendFlatMap Result.failure( - AppError(AppError.Kind.STORAGE, "Storage directory is not configured"), - ) - } - val launchRequest = request.toLaunchRequest() - val remoteStart = if (request.wearSensorIds.isNotEmpty() || request.wearIncludesGps) { - WearCommandCodec.encode( - WearCommand.StartMeasurement( - folderName = launchRequest.folderName, - sensorIds = request.wearSensorIds.toList(), - includesGps = request.wearIncludesGps, - startAtEpochMillis = launchRequest.startAtEpochMillis, - durationMillis = launchRequest.durationMillis, - measurementType = launchRequest.measurementType, - ), - ).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } - } else { - Result.success(Unit) - } - remoteStart.flatMap { - appResult(AppError.Kind.MEASUREMENT, "Launch measurement service") { - ContextCompat.startForegroundService(context, intentFactory.create(launchRequest)) - Unit - } - } - }.withAppError(AppError.Kind.MEASUREMENT, "Request measurement start") + suspend fun start(request: MeasurementRequest): AppResult = pairedRecordingCoordinator.start(request) - fun stop(): Result = appResult(AppError.Kind.MEASUREMENT, "Request measurement stop") { - val stopIntent = Intent(context, MeasurementService::class.java) - .setAction(MeasurementService.ACTION_STOP) - context.startService(stopIntent) - } + suspend fun stop(): AppResult = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST) - fun annotate(text: String, timestampMillis: Long = System.currentTimeMillis()): Result = appResult( - AppError.Kind.MEASUREMENT, - "Request measurement annotation", - ) { - val intent = Intent(context, MeasurementService::class.java) - .setAction(MeasurementService.ACTION_ANNOTATE) - .putExtra(MeasurementService.ANNOTATION_TIME, timestampMillis) - .putExtra(MeasurementService.ANNOTATION_TEXT, text) - context.startService(intent) - } - - private fun MeasurementRequest.toLaunchRequest(): MeasurementLaunchRequest = MeasurementLaunchRequest( - folderName = intentFactory.newFolderName(customName, measurementType), - useInternalStorage = false, - sensorIds = sensorIds, - sensorSamplingPeriod = samplingPeriod(samplingPeriodIndex), - includesGps = includesGps, - stopOnLowBattery = stopOnLowBattery, - useWakeLock = useWakeLock, - gpsIntervalSeconds = gpsIntervalSeconds, - gpsMinDistanceMeters = gpsMinDistanceMeters, - measurementType = measurementType, - startAtEpochMillis = System.currentTimeMillis() + delaySeconds.coerceAtLeast(0) * 1_000L + - if (wearSensorIds.isNotEmpty() || wearIncludesGps) WEAR_START_LEAD_MILLIS else 0L, - durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L, - notes = notes, - alarmOffsetsSeconds = alarmOffsetsSeconds, - activityRecognition = activityRecognition, - activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, - significantMotion = significantMotion, - controlsWearMeasurement = wearSensorIds.isNotEmpty() || wearIncludesGps, - ) - - private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) { - SensorManager.SENSOR_DELAY_FASTEST - } - - private companion object { - const val APP_DIRECTORY = "SensorBox" - val SENSOR_PERIODS = intArrayOf( - SensorManager.SENSOR_DELAY_FASTEST, - SensorManager.SENSOR_DELAY_GAME, - SensorManager.SENSOR_DELAY_UI, - SensorManager.SENSOR_DELAY_NORMAL, - ) - const val WEAR_START_LEAD_MILLIS = 1_000L - } + fun annotate(text: String, timestampMillis: Long = System.currentTimeMillis()): AppResult = + localController.annotate(text, timestampMillis) } diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt new file mode 100644 index 0000000..74f3d51 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt @@ -0,0 +1,132 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.content.Context +import android.content.Intent +import android.hardware.SensorManager +import androidx.core.content.ContextCompat +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.flatMap +import com.motionapps.sensorbox.core.storage.NativeDocumentStorage +import com.motionapps.sensorservices.intent.MeasurementIntentFactory +import com.motionapps.sensorservices.intent.MeasurementLaunchRequest +import com.motionapps.sensorservices.services.MeasurementService +import com.motionapps.wearoslib.protocol.WearStopReason +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +data class PreparedPhoneRecording(val sessionId: String, val launchRequest: MeasurementLaunchRequest) + +interface PhoneRecordingController { + suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult + + fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult + + fun abort(sessionId: String): AppResult + + fun stop(sessionId: String, reason: WearStopReason): AppResult + + fun stopAny(reason: WearStopReason): AppResult + + fun annotate(text: String, timestampMillis: Long): AppResult +} + +@Singleton +class AndroidPhoneRecordingController @Inject constructor( + @ApplicationContext private val context: Context, + private val intentFactory: MeasurementIntentFactory, +) : PhoneRecordingController { + private val committedSessions = mutableSetOf() + + override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = + NativeDocumentStorage + .hasAppDirectory(context, APP_DIRECTORY) + .flatMap { storageReady -> + if (!storageReady) { + AppResult.failure(AppError(AppErrorCode.STORAGE, "Prepare phone recording storage")) + } else { + AppResult.success( + PreparedPhoneRecording( + sessionId = sessionId, + launchRequest = request.toLaunchRequest(sessionId), + ), + ) + } + } + + override fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Commit phone recording", + ) { + val launchRequest = prepared.launchRequest.copy(startAtEpochMillis = startAtEpochMillis) + ContextCompat.startForegroundService(context, intentFactory.create(launchRequest)) + synchronized(committedSessions) { committedSessions += prepared.sessionId } + } + + override fun abort(sessionId: String): AppResult { + val wasCommitted = synchronized(committedSessions) { committedSessions.remove(sessionId) } + return if (wasCommitted) stopService("Abort phone recording") else AppResult.success(Unit) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + synchronized(committedSessions) { committedSessions.remove(sessionId) } + return stopService("Stop phone recording") + } + + override fun stopAny(reason: WearStopReason): AppResult = stopService("Stop phone recording") + + override fun annotate(text: String, timestampMillis: Long): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Request measurement annotation", + ) { + val intent = Intent(context, MeasurementService::class.java) + .setAction(MeasurementService.ACTION_ANNOTATE) + .putExtra(MeasurementService.ANNOTATION_TIME, timestampMillis) + .putExtra(MeasurementService.ANNOTATION_TEXT, text) + context.startService(intent) + } + + private fun stopService(operation: String): AppResult = appResult(AppErrorCode.MEASUREMENT, operation) { + val stopIntent = Intent(context, MeasurementService::class.java) + .setAction(MeasurementService.ACTION_STOP) + context.startService(stopIntent) + } + + private fun MeasurementRequest.toLaunchRequest(sessionId: String): MeasurementLaunchRequest = + MeasurementLaunchRequest( + sessionId = sessionId, + folderName = intentFactory.newFolderName(customName, measurementType), + useInternalStorage = false, + sensorIds = sensorIds, + sensorSamplingPeriod = samplingPeriod(samplingPeriodIndex), + includesGps = includesGps, + stopOnLowBattery = stopOnLowBattery, + useWakeLock = useWakeLock, + gpsIntervalSeconds = gpsIntervalSeconds, + gpsMinDistanceMeters = gpsMinDistanceMeters, + measurementType = measurementType, + durationMillis = durationSeconds.coerceAtLeast(0) * 1_000L, + notes = notes, + alarmOffsetsSeconds = alarmOffsetsSeconds, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, + ) + + private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) { + SensorManager.SENSOR_DELAY_FASTEST + } + + private companion object { + const val APP_DIRECTORY = "SensorBox" + val SENSOR_PERIODS = intArrayOf( + SensorManager.SENSOR_DELAY_FASTEST, + SensorManager.SENSOR_DELAY_GAME, + SensorManager.SENSOR_DELAY_UI, + SensorManager.SENSOR_DELAY_NORMAL, + ) + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt new file mode 100644 index 0000000..bf29acf --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt @@ -0,0 +1,53 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.content.Intent +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.domain.sensors.GetAvailableSensorsUseCase +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import javax.inject.Inject + +interface RecordingWorkflowGateway { + fun sensors(): List + + fun storagePath(): String? + + fun hasStorage(): Boolean + + fun persistStorage(resultIntent: Intent?): AppResult + + fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set + + suspend fun start(request: MeasurementRequest): AppResult + + suspend fun stop(): AppResult + + fun annotate(text: String): AppResult +} + +class AndroidRecordingWorkflowGateway @Inject constructor( + private val getAvailableSensors: GetAvailableSensorsUseCase, + private val documentStorage: DocumentStorageUseCase, + private val measurementPermissions: MeasurementPermissionUseCase, + private val measurementControl: MeasurementControlUseCase, +) : RecordingWorkflowGateway { + override fun sensors(): List = getAvailableSensors() + + override fun storagePath(): String? = documentStorage.displayPath().getOrNull() + + override fun hasStorage(): Boolean = documentStorage.hasStorage().getOrNull() == true + + override fun persistStorage(resultIntent: Intent?): AppResult = resultIntent + ?.let(documentStorage::persist) + ?: AppResult.failure(AppError(AppErrorCode.STORAGE, "Select recording storage directory")) + + override fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = + measurementPermissions.missingPermissions(request, includesHeartRate) + + override suspend fun start(request: MeasurementRequest): AppResult = measurementControl.start(request) + + override suspend fun stop(): AppResult = measurementControl.stop() + + override fun annotate(text: String): AppResult = measurementControl.annotate(text) +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt new file mode 100644 index 0000000..9f3b070 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt @@ -0,0 +1,265 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController +import com.motionapps.sensorbox.domain.measurement.PreparedPhoneRecording +import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearRecordingRequest +import com.motionapps.wearoslib.protocol.WearSessionCommand +import com.motionapps.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +data class PairedRecordingSession(val sessionId: String, val controlsWear: Boolean) + +class RecordingSessionIdFactory @Inject constructor() { + fun create(): String = UUID.randomUUID().toString() +} + +@Singleton +class PairedRecordingCoordinator @Inject constructor( + private val localController: PhoneRecordingController, + private val sendCommand: SendWearCommandUseCase, + private val acknowledgementInbox: WearAcknowledgementInbox, + private val sessionIdFactory: RecordingSessionIdFactory, + private val diagnosticLogger: DiagnosticLogger, +) { + private val mutex = Mutex() + private val mutableSession = MutableStateFlow(null) + + val session: StateFlow = mutableSession.asStateFlow() + + suspend fun start(request: MeasurementRequest): AppResult = mutex.withLock { + if (mutableSession.value != null) return@withLock conflict("Start recording") + val sessionId = sessionIdFactory.create() + when (val preparation = prepareBoth(sessionId, request)) { + is AppResult.Failure -> record(preparation) + + is AppResult.Success -> when (val commit = commitBoth(preparation.value, request)) { + is AppResult.Failure -> record(commit) + + is AppResult.Success -> { + mutableSession.value = PairedRecordingSession(sessionId, preparation.value.controlsWear) + AppResult.success(Unit) + } + } + } + } + + private suspend fun prepareBoth(sessionId: String, request: MeasurementRequest): AppResult = + when (val local = localController.prepare(sessionId, request)) { + is AppResult.Failure -> local + is AppResult.Success -> prepareWearIfNeeded(sessionId, request, local.value) + } + + private suspend fun prepareWearIfNeeded( + sessionId: String, + request: MeasurementRequest, + prepared: PreparedPhoneRecording, + ): AppResult { + val controlsWear = request.wearSensorIds.isNotEmpty() || request.wearIncludesGps + if (!controlsWear) return AppResult.success(PairedPreparation(prepared, controlsWear = false)) + val result = exchange( + command = WearCommand.PrepareRecording(sessionId, prepared.toWearRequest(request)), + expected = WearSessionCommand.PREPARE, + timeoutMillis = PREPARE_TIMEOUT_MILLIS, + ) + return when (result) { + is AppResult.Success -> AppResult.success(PairedPreparation(prepared, controlsWear = true)) + + is AppResult.Failure -> { + compensate(sessionId, prepared, localWasCommitted = false) + result + } + } + } + + private suspend fun commitBoth(preparation: PairedPreparation, request: MeasurementRequest): AppResult { + val sessionId = preparation.prepared.sessionId + val startAtEpochMillis = System.currentTimeMillis() + request.delaySeconds.coerceAtLeast(0) * 1_000L + + if (preparation.controlsWear) PAIRED_START_LEAD_MILLIS else 0L + val local = localController.commit(preparation.prepared, startAtEpochMillis) + if (local is AppResult.Failure) { + if (preparation.controlsWear) compensate(sessionId, preparation.prepared, localWasCommitted = false) + return local + } + if (!preparation.controlsWear) return AppResult.success(Unit) + val remote = exchange( + command = WearCommand.CommitRecording(sessionId, startAtEpochMillis), + expected = WearSessionCommand.COMMIT, + timeoutMillis = COMMIT_TIMEOUT_MILLIS, + ) + if (remote is AppResult.Failure) { + compensate(sessionId, preparation.prepared, localWasCommitted = true) + } + return remote + } + + suspend fun stop(reason: WearStopReason = WearStopReason.USER_REQUEST): AppResult = mutex.withLock { + val active = mutableSession.value + ?: return@withLock localController.stopAny(reason).onFailure { record(it) } + val localStop = localController.stop(active.sessionId, reason) + val remoteStop = if (active.controlsWear) { + exchange( + command = WearCommand.StopRecording(active.sessionId, reason), + expected = WearSessionCommand.STOP, + timeoutMillis = COMMIT_TIMEOUT_MILLIS, + ) + } else { + AppResult.success(Unit) + } + mutableSession.value = null + listOf(localStop, remoteStop) + .combineAppResults(AppErrorCode.MEASUREMENT, "Stop paired recording") + .onFailure { record(it) } + } + + suspend fun onAutomaticLocalStop(sessionId: String, reason: WearStopReason): AppResult = mutex.withLock { + val active = mutableSession.value + if (active?.sessionId != sessionId) return@withLock AppResult.success(Unit) + val remoteStop = if (active.controlsWear) { + exchange( + command = WearCommand.StopRecording(active.sessionId, reason), + expected = WearSessionCommand.STOP, + timeoutMillis = COMMIT_TIMEOUT_MILLIS, + ) + } else { + AppResult.success(Unit) + } + mutableSession.value = null + remoteStop.onFailure { record(it) } + } + + suspend fun stopFromPeer(sessionId: String, reason: WearStopReason): AppResult = mutex.withLock { + val result = localController.stop(sessionId, reason) + if (mutableSession.value?.sessionId == sessionId) mutableSession.value = null + result.onFailure { record(it) } + } + + private suspend fun exchange( + command: WearCommand, + expected: WearSessionCommand, + timeoutMillis: Long, + ): AppResult { + val sessionId = command.sessionId() + acknowledgementInbox.clear(sessionId, expected) + var lastSendError: AppError? = null + repeat(ATTEMPT_COUNT) { retryCount -> + when (val sent = sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command)) { + is AppResult.Failure -> lastSendError = sent.error + + is AppResult.Success -> { + lastSendError = null + val acknowledgement = withTimeoutOrNull(timeoutMillis / ATTEMPT_COUNT) { + acknowledgementInbox.await(sessionId, expected) + } + if (acknowledgement != null) return acknowledgement.toResult(retryCount) + } + } + } + return lastSendError?.let { error -> AppResult.failure(error) } ?: AppResult.failure( + AppError( + code = AppErrorCode.TIMEOUT, + operation = "Await Wear $expected acknowledgement", + diagnosticMessage = "Wear $expected acknowledgement timed out", + context = mapOf( + "sessionId" to sessionId, + "retryCount" to RETRY_COUNT.toString(), + "protocolVersion" to "2", + ), + isRetryable = true, + ), + ) + } + + private suspend fun compensate(sessionId: String, prepared: PreparedPhoneRecording, localWasCommitted: Boolean) { + if (localWasCommitted) localController.abort(prepared.sessionId) else localController.abort(sessionId) + exchange( + command = WearCommand.AbortRecording(sessionId), + expected = WearSessionCommand.ABORT, + timeoutMillis = COMMIT_TIMEOUT_MILLIS, + ) + } + + private fun PreparedPhoneRecording.toWearRequest(request: MeasurementRequest) = WearRecordingRequest( + folderName = launchRequest.folderName, + sensorIds = request.wearSensorIds.sorted(), + includesGps = request.wearIncludesGps, + durationMillis = launchRequest.durationMillis, + measurementType = launchRequest.measurementType, + ) + + private fun WearCommand.Acknowledgement.toResult(retryCount: Int): AppResult = + if (outcome == WearAcknowledgementOutcome.SUCCEEDED) { + AppResult.success(Unit) + } else { + AppResult.failure( + AppError( + code = errorCode ?: AppErrorCode.UNKNOWN, + operation = "Handle Wear $command acknowledgement", + diagnosticMessage = "Wear $command acknowledgement was $outcome", + context = mapOf( + "sessionId" to sessionId, + "retryCount" to retryCount.toString(), + "failureCount" to failureCount.toString(), + "protocolVersion" to "2", + ), + ), + ) + } + + private fun WearCommand.sessionId(): String = when (this) { + is WearCommand.PrepareRecording -> sessionId + + is WearCommand.CommitRecording -> sessionId + + is WearCommand.AbortRecording -> sessionId + + is WearCommand.StopRecording -> sessionId + + is WearCommand.Acknowledgement -> sessionId + + WearCommand.LaunchPhone, + WearCommand.SyncMeasurements, + WearCommand.RequestSensorList, + is WearCommand.SensorList, + -> error("Wear command has no recording session") + } + + private fun conflict(operation: String): AppResult = AppResult.failure( + AppError(AppErrorCode.CONFLICT, operation), + ) + + private fun record(result: AppResult): AppResult = result.onFailure { record(it) } + + private fun record(error: AppError) { + diagnosticLogger.record(error.toDiagnosticEvent()) + } + + private data class PairedPreparation(val prepared: PreparedPhoneRecording, val controlsWear: Boolean) + + private companion object { + const val PREPARE_TIMEOUT_MILLIS = 10_000L + const val COMMIT_TIMEOUT_MILLIS = 5_000L + const val PAIRED_START_LEAD_MILLIS = 6_000L + const val RETRY_COUNT = 2 + const val ATTEMPT_COUNT = RETRY_COUNT + 1 + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt new file mode 100644 index 0000000..2b0a5ab --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt @@ -0,0 +1,49 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.sensorservices.session.MeasurementSessionEvent +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.sensorservices.session.MeasurementStopReason +import com.motionapps.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PhoneRecordingSessionObserver @Inject constructor( + private val sessionStore: MeasurementSessionStore, + private val pairedRecordingCoordinator: PairedRecordingCoordinator, + private val diagnosticLogger: DiagnosticLogger, +) { + private val started = AtomicBoolean(false) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun start() { + if (!started.compareAndSet(false, true)) return + scope.launch { + sessionStore.events.collect { event -> + if (event is MeasurementSessionEvent.Stopped) onStopped(event) + } + } + } + + private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) { + event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } + val reason = event.reason.toAutomaticWearReason() ?: return + pairedRecordingCoordinator.onAutomaticLocalStop(event.sessionId, reason) + } + + private fun MeasurementStopReason.toAutomaticWearReason(): WearStopReason? = when (this) { + MeasurementStopReason.USER_REQUEST -> null + MeasurementStopReason.DURATION_EXPIRED -> WearStopReason.DURATION_EXPIRED + MeasurementStopReason.LOW_BATTERY -> WearStopReason.LOW_BATTERY + MeasurementStopReason.SOURCE_FAILURE -> WearStopReason.SOURCE_FAILURE + MeasurementStopReason.SERVICE_DESTROYED -> WearStopReason.SERVICE_DESTROYED + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearCommandHandler.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearCommandHandler.kt new file mode 100644 index 0000000..bc900e7 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearCommandHandler.kt @@ -0,0 +1,77 @@ +package com.motionapps.sensorbox.domain.paired + +import android.content.Context +import android.content.Intent +import com.motionapps.sensorbox.activities.MainActivity +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.domain.sensors.WearSensorCatalogStore +import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearSessionCommand +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +fun interface PhoneWearCommandPolicy { + suspend fun handle(command: WearCommand): AppResult +} + +class PhoneWearCommandHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val wearSensorCatalog: WearSensorCatalogStore, + private val acknowledgementInbox: WearAcknowledgementInbox, + private val pairedRecordingCoordinator: PairedRecordingCoordinator, + private val sendCommand: SendWearCommandUseCase, +) : PhoneWearCommandPolicy { + override suspend fun handle(command: WearCommand): AppResult = when (command) { + WearCommand.LaunchPhone -> launchPhone() + + is WearCommand.SensorList -> { + wearSensorCatalog.update(command.sensors) + AppResult.success(Unit) + } + + is WearCommand.Acknowledgement -> { + acknowledgementInbox.publish(command) + AppResult.success(Unit) + } + + is WearCommand.StopRecording -> stopFromWear(command) + + WearCommand.RequestSensorList, + WearCommand.SyncMeasurements, + is WearCommand.PrepareRecording, + is WearCommand.CommitRecording, + is WearCommand.AbortRecording, + -> AppResult.failure(AppError(AppErrorCode.VALIDATION, "Handle unsupported phone Wear command")) + } + + private fun launchPhone(): AppResult = appResult(AppErrorCode.EXTERNAL_ACTION, "Launch phone app from Wear") { + context.startActivity( + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + ) + } + + private suspend fun stopFromWear(command: WearCommand.StopRecording): AppResult { + val stopResult = pairedRecordingCoordinator.stopFromPeer(command.sessionId, command.reason) + val acknowledgement = WearCommand.Acknowledgement( + sessionId = command.sessionId, + command = WearSessionCommand.STOP, + outcome = if (stopResult.isSuccess) { + WearAcknowledgementOutcome.SUCCEEDED + } else { + WearAcknowledgementOutcome.FAILED + }, + errorCode = stopResult.errorOrNull()?.code, + failureCount = if (stopResult.isFailure) 1 else 0, + ) + return sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, acknowledgement) + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt new file mode 100644 index 0000000..720553d --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.WearCommandCodec +import javax.inject.Inject + +class PhoneWearMessageDispatcher @Inject constructor( + private val commandHandler: PhoneWearCommandPolicy, + private val diagnosticLogger: DiagnosticLogger, +) { + suspend fun dispatch(path: String, payload: ByteArray): AppResult { + if (path != PHONE_MESSAGE_PATH) return AppResult.success(Unit) + return WearCommandCodec.decode(payload) + .suspendFlatMap(commandHandler::handle) + .onFailure { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/paired/WearAcknowledgementInbox.kt b/app/src/main/java/com/motionapps/sensorbox/domain/paired/WearAcknowledgementInbox.kt new file mode 100644 index 0000000..d8e0134 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/WearAcknowledgementInbox.kt @@ -0,0 +1,38 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearSessionCommand +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearAcknowledgementInbox @Inject constructor() { + private val acknowledgements = ConcurrentHashMap() + private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) + + fun publish(acknowledgement: WearCommand.Acknowledgement) { + acknowledgements[acknowledgement.key()] = acknowledgement + updates.tryEmit(acknowledgement) + } + + fun clear(sessionId: String, command: WearSessionCommand) { + acknowledgements.remove(Key(sessionId, command)) + } + + suspend fun await(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement { + val key = Key(sessionId, command) + acknowledgements[key]?.let { return it } + return updates.first { acknowledgement -> acknowledgement.key() == key } + } + + private fun WearCommand.Acknowledgement.key() = Key(sessionId, command) + + private data class Key(val sessionId: String, val command: WearSessionCommand) + + private companion object { + const val BUFFER_SIZE = 32 + } +} diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt similarity index 86% rename from WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt rename to app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt index 55c2e72..97ea697 100644 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt @@ -1,5 +1,6 @@ -package com.motionapps.wearoslib.protocol +package com.motionapps.sensorbox.domain.sensors +import com.motionapps.wearoslib.protocol.WearSensorInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/sync/ReceiveWearFileUseCase.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sync/ReceiveWearFileUseCase.kt index fae65d8..b36512a 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/sync/ReceiveWearFileUseCase.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sync/ReceiveWearFileUseCase.kt @@ -2,6 +2,8 @@ package com.motionapps.sensorbox.domain.sync import com.google.android.gms.wearable.ChannelClient import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.suspendFlatMap import com.motionapps.wearoslib.files.WearFilePathCodec @@ -12,14 +14,14 @@ class ReceiveWearFileUseCase @Inject constructor( private val transferClient: WearFileTransferClient, private val destination: WearFileDestination, ) { - suspend operator fun invoke(channel: ChannelClient.Channel): Result = + suspend operator fun invoke(channel: ChannelClient.Channel): AppResult = WearFilePathCodec.decode(channel.path).suspendFlatMap { metadata -> destination.isReady().flatMap { ready -> if (ready) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.STORAGE, "Receive Wear file"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Receive Wear file"), ) } }.suspendFlatMap { diff --git a/app/src/main/java/com/motionapps/sensorbox/domain/sync/WearFileDestination.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sync/WearFileDestination.kt index 88c584d..a375f56 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/sync/WearFileDestination.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sync/WearFileDestination.kt @@ -3,6 +3,8 @@ package com.motionapps.sensorbox.domain.sync import android.content.Context import android.content.pm.ApplicationInfo import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.storage.NativeDocumentStorage @@ -15,11 +17,14 @@ import javax.inject.Singleton @Singleton class WearFileDestination @Inject constructor(@ApplicationContext private val context: Context) { - fun isReady(): Result = hasConfiguredDirectory().map { configured -> configured || isDebugBuild() } + fun isReady(): AppResult = hasConfiguredDirectory().map { configured -> configured || isDebugBuild() } - fun copy(metadata: WearFileMetadata, input: InputStream): Result { + fun copy(metadata: WearFileMetadata, input: InputStream): AppResult { val measurementName = "WEAR_${metadata.measurementName}" - val configured = hasConfiguredDirectory().getOrElse { return Result.failure(it) } + val configuredResult = hasConfiguredDirectory() + val configured = configuredResult.getOrNull() ?: return AppResult.failure( + checkNotNull(configuredResult.errorOrNull()), + ) return if (configured) { copyToConfiguredDirectory(measurementName, metadata.fileName, input) } else { @@ -27,26 +32,29 @@ class WearFileDestination @Inject constructor(@ApplicationContext private val co } } - private fun copyToConfiguredDirectory(measurementName: String, fileName: String, input: InputStream): Result = - NativeDocumentStorage.copyToMeasurement( - context = context, - input = input, - appDirectoryName = APP_DIRECTORY, - measurementName = measurementName, - fileName = fileName, - mimeType = mimeType(fileName), - ) + private fun copyToConfiguredDirectory( + measurementName: String, + fileName: String, + input: InputStream, + ): AppResult = NativeDocumentStorage.copyToMeasurement( + context = context, + input = input, + appDirectoryName = APP_DIRECTORY, + measurementName = measurementName, + fileName = fileName, + mimeType = mimeType(fileName), + ) - private fun copyToDebugDirectory(measurementName: String, fileName: String, input: InputStream): Result { - if (!isDebugBuild()) return Result.failure(AppError(AppError.Kind.STORAGE, "Copy debug Wear file")) - return appResult(AppError.Kind.STORAGE, "Prepare debug Wear directory") { + private fun copyToDebugDirectory(measurementName: String, fileName: String, input: InputStream): AppResult { + if (!isDebugBuild()) return AppResult.failure(AppError(AppErrorCode.STORAGE, "Copy debug Wear file")) + return appResult(AppErrorCode.STORAGE, "Prepare debug Wear directory") { val directory = File(context.filesDir, "$APP_DIRECTORY/$measurementName") directory to (directory.isDirectory || directory.mkdirs()) }.flatMap { (directory, ready) -> if (!ready) { - Result.failure(AppError(AppError.Kind.STORAGE, "Prepare debug Wear directory")) + AppResult.failure(AppError(AppErrorCode.STORAGE, "Prepare debug Wear directory")) } else { - appResult(AppError.Kind.STORAGE, "Copy debug Wear file") { + appResult(AppErrorCode.STORAGE, "Copy debug Wear file") { File(directory, fileName).outputStream().use(input::copyTo) Unit } @@ -54,7 +62,7 @@ class WearFileDestination @Inject constructor(@ApplicationContext private val co } } - private fun hasConfiguredDirectory(): Result = NativeDocumentStorage.hasAppDirectory( + private fun hasConfiguredDirectory(): AppResult = NativeDocumentStorage.hasAppDirectory( context, APP_DIRECTORY, ) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt index 72b3458..92c1d27 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.BuildConfig import com.motionapps.sensorbox.R -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.appResult @Composable @@ -83,7 +83,7 @@ private fun AboutDialogBody(onPrivacy: () -> Unit) { fun OpenSourceLicensesScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { val resources = LocalContext.current.resources val licenses = remember(resources) { - appResult(AppError.Kind.STORAGE, "Load open source licenses") { + appResult(AppErrorCode.STORAGE, "Load open source licenses") { loadOpenSourceLicenses(resources) }.getOrDefault(emptyList()) } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt index 5b4518b..dee1240 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt @@ -53,7 +53,7 @@ import com.motionapps.sensorbox.ui.theme.SensorBoxRecording import com.motionapps.sensorservices.session.MeasurementSessionState @Composable -fun ActiveMeasurementScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +fun ActiveMeasurementScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { val session = state.session as? MeasurementSessionState.Running ?: return Column( modifier = modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 24.dp), @@ -74,14 +74,14 @@ fun ActiveMeasurementScreen(state: MainState, onIntent: (MainIntent) -> Unit, mo } SensorBoxDangerButton( label = stringResource(R.string.stop_and_save), - onClick = { onIntent(MainIntent.StopMeasurement) }, + onClick = { onIntent(RecordingIntent.StopMeasurement) }, modifier = Modifier.fillMaxWidth(), ) } } @Composable -private fun AnnotationEditor(onIntent: (MainIntent) -> Unit) { +private fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { var annotation by remember { mutableStateOf("") } SensorBoxPanel { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -97,7 +97,7 @@ private fun AnnotationEditor(onIntent: (MainIntent) -> Unit) { label = stringResource(R.string.add_annotation), onClick = { annotation.trim().takeIf(String::isNotEmpty)?.let { - onIntent(MainIntent.AddAnnotation(it)) + onIntent(RecordingIntent.AddAnnotation(it)) annotation = "" } }, @@ -168,7 +168,7 @@ private fun MeasurementTimer(elapsedSeconds: Long, folderName: String) { } @Composable -private fun MeasurementSummary(state: MainState, session: MeasurementSessionState.Running) { +private fun MeasurementSummary(state: RecordingState, session: MeasurementSessionState.Running) { var expanded by remember { mutableStateOf(false) } val sources = recordingSourceNames(state, session, LocalContext.current.resources) SensorBoxPanel { @@ -230,7 +230,7 @@ private fun RecordingSourceList(sources: List) { } private fun recordingSourceNames( - state: MainState, + state: RecordingState, session: MeasurementSessionState.Running, resources: Resources, ): List = buildList { diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt index 9b58a77..f8bfd2c 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt @@ -1,8 +1,6 @@ package com.motionapps.sensorbox.presentation.main import androidx.navigation3.runtime.NavKey -import com.motionapps.sensorbox.core.preferences.AppPreferences -import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorservices.session.MeasurementSessionState import kotlinx.serialization.Serializable @@ -19,160 +17,9 @@ enum class MainRoute : NavKey { PRIVACY, } -enum class MainMessage { - NONE, - PICK_AT_LEAST_ONE_SOURCE, - STORAGE_REQUIRED, - PERMISSION_REQUIRED, - MEASUREMENT_FAILED, -} - data class MainState( val route: MainRoute = MainRoute.ONBOARDING, - val onboardingPage: Int = 0, - val sensors: List = emptyList(), - val wearSensors: List = emptyList(), - val detailsSensorType: Int? = null, - val selectedSensorIds: Set = emptySet(), - val includesGps: Boolean = false, - val selectedWearSensorIds: Set = emptySet(), - val wearIncludesGps: Boolean = false, - val customMeasurementName: String = "", - val measurementType: String = "ENDLESS", - 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 storagePath: String? = null, - val preferences: AppPreferences = AppPreferences(), - val hasLoadedPreferences: Boolean = false, val session: MeasurementSessionState = MeasurementSessionState.Idle, - val elapsedSeconds: Long = 0, - val isWearConnected: Boolean = false, - val message: MainMessage = MainMessage.NONE, + val keepScreenAwake: Boolean = false, + val hasLoadedPreferences: Boolean = false, ) - -sealed interface MainIntent { - data class Navigate(val route: MainRoute) : MainIntent - data class ToggleSensor(val sensorId: Int) : MainIntent - data class ToggleWearSensor(val sensorId: Int) : MainIntent - data class OpenSensorDetails(val sensorType: Int?) : MainIntent - data object ToggleGps : MainIntent - data object ToggleWearGps : MainIntent - data object AdvanceOnboarding : MainIntent - data object RetreatOnboarding : MainIntent - data object CompleteOnboarding : MainIntent - data object OpenPrivacyPolicy : MainIntent - data object OpenTermsOfUse : MainIntent - data object RequestBatteryOptimizationExemption : MainIntent - data object ShareDiagnosticsText : MainIntent - data object ShareDiagnosticsFile : MainIntent - data object ChooseStorage : MainIntent - data object OpenMeasurementSetup : MainIntent - data object ReturnToSensorSelection : MainIntent - data object StartMeasurement : MainIntent - data object StopMeasurement : MainIntent - data object ClearMessage : MainIntent - data class SetCustomMeasurementName(val value: String) : MainIntent - data class SetMeasurementType(val value: String) : MainIntent - data class SetStartDelay(val seconds: Int) : MainIntent - data class SetDuration(val seconds: Int) : MainIntent - data class SetNotes(val value: String) : MainIntent - data class SetAlarmOffsets(val value: String) : MainIntent - data class SetActivityRecognition(val enabled: Boolean) : MainIntent - data class SetActivityRecognitionPeriod(val seconds: Int) : MainIntent - data class SetSignificantMotion(val enabled: Boolean) : MainIntent - data class AddAnnotation(val text: String) : MainIntent - data class SetSamplingPeriod(val index: Int) : MainIntent - data class SetLowBatteryRestriction(val enabled: Boolean) : MainIntent - data class SetWakeLock(val enabled: Boolean) : MainIntent - data class SetKeepScreenAwake(val enabled: Boolean) : MainIntent - data class SetGpsInterval(val seconds: Int) : MainIntent - data class SetGpsDistance(val meters: Int) : MainIntent -} - -sealed interface MainEffect { - data object PickStorageDirectory : MainEffect - data object OpenPrivacyPolicy : MainEffect - data object OpenTermsOfUse : MainEffect - data object RequestBatteryOptimizationExemption : MainEffect - data object ShareDiagnosticsText : MainEffect - data object ShareDiagnosticsFile : MainEffect - data class RequestPermissions(val permissions: Set) : MainEffect -} - -data class MainNext(val state: MainState, val effect: MainEffect? = null) - -object MainReducer { - fun reduce(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.AdvanceOnboarding -> MainNext(state.copy(onboardingPage = state.onboardingPage + 1)) - MainIntent.RetreatOnboarding -> MainNext(state.retreatOnboarding()) - is MainIntent.OpenSensorDetails -> MainNext(state.openSensorDetails(intent.sensorType)) - else -> reduceGeneralIntent(state, intent) - } - - private fun reduceGeneralIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.ChooseStorage -> MainNext(state, MainEffect.PickStorageDirectory) - MainIntent.ClearMessage -> MainNext(state.copy(message = MainMessage.NONE)) - MainIntent.OpenPrivacyPolicy -> MainNext(state, MainEffect.OpenPrivacyPolicy) - MainIntent.OpenTermsOfUse -> MainNext(state, MainEffect.OpenTermsOfUse) - MainIntent.RequestBatteryOptimizationExemption -> batteryOptimizationEffect(state) - MainIntent.ShareDiagnosticsText -> MainNext(state, MainEffect.ShareDiagnosticsText) - MainIntent.ShareDiagnosticsFile -> MainNext(state, MainEffect.ShareDiagnosticsFile) - MainIntent.OpenMeasurementSetup -> MainNext(state.openMeasurementSetup()) - MainIntent.ReturnToSensorSelection -> MainNext(state.returnToSensorSelection()) - else -> reduceSelectionIntent(state, intent) - } - - private fun reduceSelectionIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.ToggleGps -> MainNext(state.copy(includesGps = !state.includesGps)) - MainIntent.ToggleWearGps -> MainNext(state.copy(wearIncludesGps = !state.wearIncludesGps)) - is MainIntent.Navigate -> MainNext(state.copy(route = intent.route)) - is MainIntent.ToggleSensor -> MainNext(state.toggleSensor(intent.sensorId)) - is MainIntent.ToggleWearSensor -> MainNext(state.toggleWearSensor(intent.sensorId)) - else -> reduceConfigurationIntent(state, intent) - } - - private fun reduceConfigurationIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - is MainIntent.SetCustomMeasurementName -> MainNext(state.copy(customMeasurementName = intent.value)) - is MainIntent.SetMeasurementType -> MainNext(state.copy(measurementType = intent.value)) - is MainIntent.SetStartDelay -> MainNext(state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0))) - is MainIntent.SetDuration -> MainNext(state.copy(durationSeconds = intent.seconds.coerceAtLeast(0))) - is MainIntent.SetNotes -> MainNext(state.copy(notes = intent.value)) - is MainIntent.SetAlarmOffsets -> MainNext(state.copy(alarmOffsets = intent.value)) - is MainIntent.SetActivityRecognition -> MainNext(state.copy(activityRecognition = intent.enabled)) - is MainIntent.SetActivityRecognitionPeriod -> MainNext(state.withActivityPeriod(intent.seconds)) - is MainIntent.SetSignificantMotion -> MainNext(state.copy(significantMotion = intent.enabled)) - else -> MainNext(state) - } - - private fun batteryOptimizationEffect(state: MainState) = - MainNext(state, MainEffect.RequestBatteryOptimizationExemption) - - private fun MainState.retreatOnboarding() = copy(onboardingPage = (onboardingPage - 1).coerceAtLeast(0)) - - private fun MainState.openSensorDetails(sensorType: Int?) = - copy(route = MainRoute.SENSOR_DETAILS, detailsSensorType = sensorType) - - private fun MainState.openMeasurementSetup() = copy(route = MainRoute.SETUP, message = MainMessage.NONE) - - private fun MainState.returnToSensorSelection() = copy(route = MainRoute.RECORD, message = MainMessage.NONE) - - private fun MainState.toggleSensor(sensorId: Int): MainState { - val updated = selectedSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedSensorIds = updated, message = MainMessage.NONE) - } - - private fun MainState.toggleWearSensor(sensorId: Int): MainState { - val updated = selectedWearSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedWearSensorIds = updated, message = MainMessage.NONE) - } -} - -private fun MainState.withActivityPeriod(seconds: Int) = - copy(activityRecognitionPeriodSeconds = seconds.coerceAtLeast(1)) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt index b92ce7c..6decf57 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt @@ -1,322 +1,72 @@ package com.motionapps.sensorbox.presentation.main -import android.content.Intent -import android.os.SystemClock import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.suspendFlatMap -import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository -import com.motionapps.sensorbox.domain.measurement.DocumentStorageUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementControlUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementPermissionUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementRequest -import com.motionapps.sensorbox.domain.sensors.GetAvailableSensorsUseCase -import com.motionapps.sensorbox.domain.sensors.SensorDescriptor -import com.motionapps.sensorservices.session.MeasurementSessionState import com.motionapps.sensorservices.session.MeasurementSessionStore -import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.connectivity.WearConnection -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec -import com.motionapps.wearoslib.protocol.WearSensorCatalogStore 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 MainViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, - private val getAvailableSensors: GetAvailableSensorsUseCase, - private val documentStorage: DocumentStorageUseCase, - private val measurementPermissions: MeasurementPermissionUseCase, - private val measurementControl: MeasurementControlUseCase, private val sessionStore: MeasurementSessionStore, - private val observeWearCapability: ObserveWearCapabilityUseCase, - private val sendWearMessage: SendWearMessageUseCase, - private val wearSensorCatalog: WearSensorCatalogStore, ) : ViewModel() { - private val mutableState = MutableStateFlow(initialState()) - private val mutableEffects = Channel(Channel.BUFFERED) + private val mutableState = MutableStateFlow(MainState()) private var hasChosenInitialRoute = false - private var elapsedJob: Job? = null val state: StateFlow = mutableState.asStateFlow() - val effects = mutableEffects.receiveAsFlow() init { observePreferences() - observeMeasurementSession() - observeWearConnection() - observeWearSensors() + observeSession() } - fun accept(intent: MainIntent) { - when (intent) { - MainIntent.CompleteOnboarding -> completeOnboarding() - - MainIntent.StartMeasurement -> startMeasurement() - - MainIntent.StopMeasurement -> measurementControl.stop().showFailure() - - is MainIntent.AddAnnotation -> measurementControl.annotate(intent.text).showFailure() - - is MainIntent.SetSamplingPeriod -> updatePreference( - AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), - ) - - is MainIntent.SetLowBatteryRestriction -> updatePreference( - AppPreferencesIntent.SetLowBatteryRestriction(intent.enabled), - ) - - is MainIntent.SetWakeLock -> updatePreference(AppPreferencesIntent.SetWakeLock(intent.enabled)) - - is MainIntent.SetKeepScreenAwake -> updatePreference( - AppPreferencesIntent.SetKeepPhoneDisplayOn(intent.enabled), - ) - - is MainIntent.SetGpsInterval -> updatePreference(AppPreferencesIntent.SetGpsInterval(intent.seconds)) - - is MainIntent.SetGpsDistance -> updatePreference(AppPreferencesIntent.SetGpsMinDistance(intent.meters)) - - else -> reduce(intent) - } - } - - fun handleStorageResult(resultIntent: Intent?) { - val persisted = resultIntent?.let(documentStorage::persist) ?: Result.failure( - com.motionapps.sensorbox.core.error.AppError( - com.motionapps.sensorbox.core.error.AppError.Kind.STORAGE, - "Select storage directory", - ), - ) - mutableState.value = state.value.copy( - storagePath = documentStorage.displayPath().getOrNull(), - message = if (persisted.isSuccess) MainMessage.NONE else MainMessage.STORAGE_REQUIRED, - ) - } - - fun handlePermissionResult() { - val request = state.value.toMeasurementRequest() - val missing = measurementPermissions.missingPermissions(request, state.value.includesHeartRate()) - if (missing.isEmpty()) startMeasurement() else showMessage(MainMessage.PERMISSION_REQUIRED) + fun navigate(route: MainRoute) { + hasChosenInitialRoute = true + mutableState.value = state.value.copy(route = route) } fun showPrivacyRationale() { - hasChosenInitialRoute = true - reduce(MainIntent.Navigate(MainRoute.PRIVACY)) + navigate(MainRoute.PRIVACY) } - private fun initialState() = MainState( - sensors = getAvailableSensors(), - storagePath = documentStorage.displayPath().getOrNull(), - ) - private fun observePreferences() { viewModelScope.launch { preferencesRepository.preferences.collect { result -> result.fold( onSuccess = { preferences -> - val route = initialRoute(preferences.hasCompletedIntro, preferences.hasAcceptedPolicy) + val route = if (hasChosenInitialRoute) { + state.value.route + } else if (preferences.hasCompletedIntro && preferences.hasAcceptedPolicy) { + MainRoute.RECORD + } else { + MainRoute.ONBOARDING + } + hasChosenInitialRoute = true mutableState.value = state.value.copy( - preferences = preferences, - hasLoadedPreferences = true, route = route, + keepScreenAwake = preferences.keepPhoneDisplayOn, + hasLoadedPreferences = true, ) }, onFailure = { - mutableState.value = state.value.copy( - hasLoadedPreferences = true, - message = MainMessage.MEASUREMENT_FAILED, - ) + mutableState.value = state.value.copy(hasLoadedPreferences = true) }, ) } } } - private fun initialRoute(completedIntro: Boolean, acceptedPolicy: Boolean): MainRoute { - if (hasChosenInitialRoute) return state.value.route - hasChosenInitialRoute = true - return if (completedIntro && acceptedPolicy) MainRoute.RECORD else MainRoute.ONBOARDING - } - - private fun observeMeasurementSession() { + private fun observeSession() { viewModelScope.launch { sessionStore.state.collect { session -> - onSessionChanged(session) - } - } - } - - private fun onSessionChanged(session: MeasurementSessionState) { - elapsedJob?.cancel() - mutableState.value = state.value.copy(session = session, elapsedSeconds = 0) - if (session is MeasurementSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) - } - - private fun startElapsedTicker(startedAt: Long) { - elapsedJob = viewModelScope.launch { - while (isActive) { - val seconds = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) / 1_000L - mutableState.value = state.value.copy(elapsedSeconds = seconds) - delay(1_000L) - } - } - } - - private fun observeWearConnection() { - viewModelScope.launch { - observeWearCapability(WEAR_APP_CAPABILITY) - .catch { error -> - AppError.from(AppError.Kind.CONNECTIVITY, "Observe Wear connection", error) - emit(WearConnection.Disconnected) - } - .collect { connection -> - mutableState.value = state.value.copy(isWearConnected = connection is WearConnection.Connected) - if (connection is WearConnection.Connected) { - WearCommandCodec.encode(WearCommand.RequestSensorList).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } - } else { - wearSensorCatalog.clear() - } - } - } - } - - private fun observeWearSensors() { - viewModelScope.launch { - wearSensorCatalog.sensors.collect { sensors -> - mutableState.value = state.value.copy( - wearSensors = sensors.map { sensor -> - SensorDescriptor( - type = sensor.type, - name = sensor.name, - vendor = sensor.vendor, - isHeartRate = sensor.isHeartRate, - ) - }, - ) - } - } - } - - private fun reduce(intent: MainIntent) { - val next = MainReducer.reduce(state.value, intent) - mutableState.value = next.state - next.effect?.let(mutableEffects::trySend) - } - - private fun completeOnboarding() { - if (!documentStorage.hasStorage().getOrElse { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - ) { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - viewModelScope.launch { - preferencesRepository.dispatch(AppPreferencesIntent.AcceptPolicy).getOrElse { - showMessage(MainMessage.MEASUREMENT_FAILED) - return@launch - } - preferencesRepository.dispatch(AppPreferencesIntent.CompleteIntro).getOrElse { - showMessage(MainMessage.MEASUREMENT_FAILED) - return@launch + mutableState.value = state.value.copy(session = session) } - mutableState.value = state.value.copy(route = MainRoute.RECORD) } } - - @Suppress("ReturnCount") - private fun startMeasurement() { - val request = state.value.toMeasurementRequest() - if (!request.hasAnySource()) { - showMessage(MainMessage.PICK_AT_LEAST_ONE_SOURCE) - return - } - if (!documentStorage.hasStorage().getOrElse { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - ) { - reduce(MainIntent.ChooseStorage) - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - requestMissingPermissions(request)?.let { - mutableEffects.trySend(MainEffect.RequestPermissions(it)) - return - } - startForegroundMeasurement(request) - } - - private fun requestMissingPermissions(request: MeasurementRequest): Set? { - val permissions = measurementPermissions.missingPermissions(request, state.value.includesHeartRate()) - return permissions.takeIf(Set::isNotEmpty) - } - - private fun startForegroundMeasurement(request: MeasurementRequest) { - viewModelScope.launch { - val result = measurementControl.start(request) - if (result.isFailure) showMessage(MainMessage.MEASUREMENT_FAILED) - } - } - - private fun updatePreference(intent: AppPreferencesIntent) { - viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } - } - - private fun showMessage(message: MainMessage) { - mutableState.value = state.value.copy(message = message) - } - - private fun Result<*>.showFailure() { - if (isFailure) showMessage(MainMessage.MEASUREMENT_FAILED) - } - - private fun MainState.toMeasurementRequest() = MeasurementRequest( - sensorIds = selectedSensorIds, - includesGps = includesGps, - samplingPeriodIndex = preferences.sensorSamplingPeriod, - stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, - useWakeLock = preferences.useWakeLock, - gpsIntervalSeconds = preferences.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, - wearSensorIds = selectedWearSensorIds, - wearIncludesGps = wearIncludesGps, - customName = customMeasurementName, - measurementType = measurementType, - delaySeconds = startDelaySeconds, - durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 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, - ) - - private fun MainState.includesHeartRate(): Boolean = sensors.any { - it.isHeartRate && it.type in selectedSensorIds - } - - private fun MeasurementRequest.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || - wearSensorIds.isNotEmpty() || wearIncludesGps || activityRecognition || significantMotion } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt index 5c0375d..eaa6cd3 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt @@ -29,10 +29,10 @@ import com.motionapps.sensorbox.R @Composable fun MeasurementSetupScreen( - state: MainState, - onIntent: (MainIntent) -> Unit, + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier, - onBack: () -> Unit = { onIntent(MainIntent.ReturnToSensorSelection) }, + onBack: () -> Unit = { onIntent(RecordingIntent.ReturnToSensorSelection) }, ) { Box(modifier.fillMaxSize()) { MeasurementSetupContent(state, onIntent, onBack) @@ -41,17 +41,21 @@ fun MeasurementSetupScreen( } @Composable -private fun MeasurementSetupContent(state: MainState, onIntent: (MainIntent) -> Unit, onBack: () -> Unit) { +private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit, onBack: () -> Unit) { LazyColumn( contentPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp, bottom = 132.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { SensorBoxTopAppBar(stringResource(R.string.measurement_setup), onBack) } - item { StorageSetupPanel(state.storagePath) { onIntent(MainIntent.ChooseStorage) } } + item { StorageSetupPanel(state.storagePath) { onIntent(RecordingIntent.ChooseStorage) } } item { MeasurementNameSetup(state, onIntent) } item { TimingSetup(state, onIntent) } item { NotesAndAlarmsSetup(state, onIntent) } - item { SamplingSetting(state.preferences.sensorSamplingPeriod, onIntent) } + item { + SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + onIntent(RecordingIntent.SetSamplingPeriod(index)) + } + } item { SpecializedSourcesSetup(state, onIntent) } item { BatterySetup(state, onIntent) } item { WakeLockSetup(state, onIntent) } @@ -60,16 +64,16 @@ private fun MeasurementSetupContent(state: MainState, onIntent: (MainIntent) -> item { GpsIntervalSetup(state, onIntent) } item { GpsDistanceSetup(state, onIntent) } } - item { MainMessageText(state.message) } + item { RecordingMessageText(state.message) } } } @Composable -private fun MeasurementNameSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { SensorBoxPanel { OutlinedTextField( value = state.customMeasurementName, - onValueChange = { onIntent(MainIntent.SetCustomMeasurementName(it)) }, + onValueChange = { onIntent(RecordingIntent.SetCustomMeasurementName(it)) }, modifier = Modifier.fillMaxWidth().padding(16.dp), label = { Text(stringResource(R.string.custom_measurement_name)) }, supportingText = { Text(stringResource(R.string.custom_measurement_name_description)) }, @@ -79,20 +83,20 @@ private fun MeasurementNameSetup(state: MainState, onIntent: (MainIntent) -> Uni } @Composable -private fun TimingSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun TimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { BooleanSetting( title = stringResource(R.string.timed_measurement), description = stringResource(R.string.timed_measurement_description), checked = state.measurementType == "TIMED", - ) { onIntent(MainIntent.SetMeasurementType(if (it) "TIMED" else "ENDLESS")) } + ) { onIntent(RecordingIntent.SetMeasurementType(if (it) "TIMED" else "ENDLESS")) } StepSetting( stringResource(R.string.start_delay), state.startDelaySeconds, pluralStringResource(R.plurals.seconds_count, state.startDelaySeconds, state.startDelaySeconds), 0, 86_400, - ) { onIntent(MainIntent.SetStartDelay(it)) } + ) { onIntent(RecordingIntent.SetStartDelay(it)) } if (state.measurementType == "TIMED") { StepSetting( stringResource(R.string.measurement_duration), @@ -104,18 +108,18 @@ private fun TimingSetup(state: MainState, onIntent: (MainIntent) -> Unit) { ), 1, 86_400, - ) { onIntent(MainIntent.SetDuration(it)) } + ) { onIntent(RecordingIntent.SetDuration(it)) } } } } @Composable -private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun NotesAndAlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = state.notes, - onValueChange = { onIntent(MainIntent.SetNotes(it)) }, + onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.measurement_notes)) }, supportingText = { Text(stringResource(R.string.measurement_notes_description)) }, @@ -123,7 +127,7 @@ private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit ) OutlinedTextField( value = state.alarmOffsets, - onValueChange = { onIntent(MainIntent.SetAlarmOffsets(it)) }, + onValueChange = { onIntent(RecordingIntent.SetAlarmOffsets(it)) }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.audible_alarm_offsets)) }, supportingText = { Text(stringResource(R.string.audible_alarm_offsets_description)) }, @@ -134,13 +138,13 @@ private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit } @Composable -private fun SpecializedSourcesSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun SpecializedSourcesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { BooleanSetting( title = stringResource(R.string.activity_recognition), description = stringResource(R.string.activity_recognition_description), checked = state.activityRecognition, - ) { onIntent(MainIntent.SetActivityRecognition(it)) } + ) { onIntent(RecordingIntent.SetActivityRecognition(it)) } if (state.activityRecognition) { StepSetting( stringResource(R.string.activity_recognition_period), @@ -152,13 +156,13 @@ private fun SpecializedSourcesSetup(state: MainState, onIntent: (MainIntent) -> ), 1, 3_600, - ) { onIntent(MainIntent.SetActivityRecognitionPeriod(it)) } + ) { onIntent(RecordingIntent.SetActivityRecognitionPeriod(it)) } } BooleanSetting( title = stringResource(R.string.significant_motion), description = stringResource(R.string.significant_motion_description), checked = state.significantMotion, - ) { onIntent(MainIntent.SetSignificantMotion(it)) } + ) { onIntent(RecordingIntent.SetSignificantMotion(it)) } } } @@ -191,34 +195,34 @@ private fun StorageSetupPanel(path: String?, onChoose: () -> Unit) { } @Composable -private fun BatterySetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_setup_description), checked = state.preferences.restrictMeasurementOnLowBattery, - ) { onIntent(MainIntent.SetLowBatteryRestriction(it)) } + ) { onIntent(RecordingIntent.SetLowBatteryRestriction(it)) } } @Composable -private fun WakeLockSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_setup_description), checked = state.preferences.useWakeLock, - ) { onIntent(MainIntent.SetWakeLock(it)) } + ) { onIntent(RecordingIntent.SetWakeLock(it)) } } @Composable -private fun KeepScreenAwakeSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_setup_description), checked = state.preferences.keepPhoneDisplayOn, - ) { onIntent(MainIntent.SetKeepScreenAwake(it)) } + ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } } @Composable -private fun GpsIntervalSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_interval), state.preferences.gpsIntervalSeconds, @@ -230,12 +234,12 @@ private fun GpsIntervalSetup(state: MainState, onIntent: (MainIntent) -> Unit) { 1, 3_600, ) { - onIntent(MainIntent.SetGpsInterval(it)) + onIntent(RecordingIntent.SetGpsInterval(it)) } } @Composable -private fun GpsDistanceSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_minimum_distance), state.preferences.gpsMinDistanceMeters, @@ -247,12 +251,16 @@ private fun GpsDistanceSetup(state: MainState, onIntent: (MainIntent) -> Unit) { 0, 10_000, ) { - onIntent(MainIntent.SetGpsDistance(it)) + onIntent(RecordingIntent.SetGpsDistance(it)) } } @Composable -private fun MeasurementSetupActionBar(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +private fun MeasurementSetupActionBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { val sourceCount = setupSourceCount(state) SensorBoxBottomAction( title = pluralStringResource(R.plurals.source_count, sourceCount, sourceCount), @@ -261,11 +269,12 @@ private fun MeasurementSetupActionBar(state: MainState, onIntent: (MainIntent) - ), buttonLabel = stringResource(R.string.start_measurement), enabled = state.storagePath != null && sourceCount > 0, - onClick = { onIntent(MainIntent.StartMeasurement) }, + onClick = { onIntent(RecordingIntent.StartMeasurement) }, modifier = modifier, ) } -private fun setupSourceCount(state: MainState): Int = state.selectedSensorIds.size + state.selectedWearSensorIds.size + - (if (state.includesGps) 1 else 0) + (if (state.wearIncludesGps) 1 else 0) + - (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) +private fun setupSourceCount(state: RecordingState): Int = + state.selectedSensorIds.size + state.selectedWearSensorIds.size + + (if (state.includesGps) 1 else 0) + (if (state.wearIncludesGps) 1 else 0) + + (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt new file mode 100644 index 0000000..d0cd807 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt @@ -0,0 +1,23 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode + +data class OnboardingState(val page: Int = 0, val storagePath: String? = null, val errorCode: AppErrorCode? = null) + +sealed interface OnboardingIntent { + data object AdvanceOnboarding : OnboardingIntent + data object RetreatOnboarding : OnboardingIntent + data object CompleteOnboarding : OnboardingIntent + data object OpenPrivacyPolicy : OnboardingIntent + data object OpenTermsOfUse : OnboardingIntent + data object RequestBatteryOptimizationExemption : OnboardingIntent + data object ChooseStorage : OnboardingIntent +} + +sealed interface OnboardingEffect { + data object PickStorageDirectory : OnboardingEffect + data object OpenPrivacyPolicy : OnboardingEffect + data object OpenTermsOfUse : OnboardingEffect + data object RequestBatteryOptimizationExemption : OnboardingEffect + data class Navigate(val route: MainRoute) : OnboardingEffect +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt index 12b6292..1901324 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R @Composable -fun OnboardingScreen(state: MainState, onIntent: (MainIntent) -> Unit) { - val pageIndex = state.onboardingPage.coerceIn(ONBOARDING_PAGES.indices) +fun OnboardingScreen(state: OnboardingState, onIntent: (OnboardingIntent) -> Unit) { + val pageIndex = state.page.coerceIn(ONBOARDING_PAGES.indices) val page = ONBOARDING_PAGES[pageIndex] BoxWithConstraints( modifier = Modifier @@ -53,9 +53,9 @@ fun OnboardingScreen(state: MainState, onIntent: (MainIntent) -> Unit) { private fun OnboardingLayout( pageIndex: Int, page: OnboardingPage, - state: MainState, + state: OnboardingState, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, ) { Column( modifier = Modifier @@ -79,13 +79,13 @@ private fun OnboardingLayout( } @Composable -private fun OnboardingHeader(pageIndex: Int, onIntent: (MainIntent) -> Unit) { +private fun OnboardingHeader(pageIndex: Int, onIntent: (OnboardingIntent) -> Unit) { Column(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth().height(52.dp)) { if (pageIndex > 0) { SensorBoxBackButton( label = stringResource(R.string.intro_back), - onClick = { onIntent(MainIntent.RetreatOnboarding) }, + onClick = { onIntent(OnboardingIntent.RetreatOnboarding) }, modifier = Modifier.align(Alignment.CenterStart), ) } @@ -118,9 +118,9 @@ private fun OnboardingProgress(pageIndex: Int) { @Composable private fun OnboardingMessage( page: OnboardingPage, - state: MainState, + state: OnboardingState, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, ) { if (isLandscape) { LandscapeOnboardingMessage(page, state, onIntent) @@ -130,7 +130,11 @@ private fun OnboardingMessage( } @Composable -private fun PortraitOnboardingMessage(page: OnboardingPage, state: MainState, onIntent: (MainIntent) -> Unit) { +private fun PortraitOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { Column( modifier = Modifier .widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH) @@ -148,7 +152,11 @@ private fun PortraitOnboardingMessage(page: OnboardingPage, state: MainState, on } @Composable -private fun LandscapeOnboardingMessage(page: OnboardingPage, state: MainState, onIntent: (MainIntent) -> Unit) { +private fun LandscapeOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { Row( modifier = Modifier .fillMaxSize() @@ -208,7 +216,7 @@ private fun OnboardingText(page: OnboardingPage) { } @Composable -private fun OnboardingPageActions(action: OnboardingAction, path: String?, onIntent: (MainIntent) -> Unit) { +private fun OnboardingPageActions(action: OnboardingAction, path: String?, onIntent: (OnboardingIntent) -> Unit) { when (action) { OnboardingAction.NONE -> Unit OnboardingAction.POLICIES -> PolicyActions(onIntent) @@ -218,38 +226,38 @@ private fun OnboardingPageActions(action: OnboardingAction, path: String?, onInt } @Composable -private fun PolicyActions(onIntent: (MainIntent) -> Unit) { +private fun PolicyActions(onIntent: (OnboardingIntent) -> Unit) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { SensorBoxSecondaryButton( label = stringResource(R.string.intro_policy_button), - onClick = { onIntent(MainIntent.OpenPrivacyPolicy) }, + onClick = { onIntent(OnboardingIntent.OpenPrivacyPolicy) }, modifier = Modifier.weight(1f), ) SensorBoxSecondaryButton( label = stringResource(R.string.intro_terms_button), - onClick = { onIntent(MainIntent.OpenTermsOfUse) }, + onClick = { onIntent(OnboardingIntent.OpenTermsOfUse) }, modifier = Modifier.weight(1f), ) } } @Composable -private fun BatteryAction(onIntent: (MainIntent) -> Unit) { +private fun BatteryAction(onIntent: (OnboardingIntent) -> Unit) { SensorBoxSecondaryButton( label = stringResource(R.string.intro_battery_action), - onClick = { onIntent(MainIntent.RequestBatteryOptimizationExemption) }, + onClick = { onIntent(OnboardingIntent.RequestBatteryOptimizationExemption) }, modifier = Modifier.fillMaxWidth(), ) } @Composable -private fun StorageAction(path: String?, onIntent: (MainIntent) -> Unit) { +private fun StorageAction(path: String?, onIntent: (OnboardingIntent) -> Unit) { Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { SensorBoxSecondaryButton( label = stringResource( if (path == null) R.string.intro_storage_action else R.string.intro_storage_change_action, ), - onClick = { onIntent(MainIntent.ChooseStorage) }, + onClick = { onIntent(OnboardingIntent.ChooseStorage) }, modifier = Modifier.fillMaxWidth(), ) if (path != null) { @@ -269,7 +277,7 @@ private fun OnboardingControls( pageIndex: Int, hasStorage: Boolean, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, modifier: Modifier = Modifier, ) { val isLastPage = pageIndex == ONBOARDING_PAGES.lastIndex @@ -285,8 +293,8 @@ private fun OnboardingControls( } } -private fun onboardingForwardIntent(isLastPage: Boolean): MainIntent = - if (isLastPage) MainIntent.CompleteOnboarding else MainIntent.AdvanceOnboarding +private fun onboardingForwardIntent(isLastPage: Boolean): OnboardingIntent = + if (isLastPage) OnboardingIntent.CompleteOnboarding else OnboardingIntent.AdvanceOnboarding private data class OnboardingPage( @StringRes val title: Int, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt new file mode 100644 index 0000000..1dbf2f0 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt @@ -0,0 +1,86 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway +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.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val documentStorage: DocumentStorageGateway, +) : ViewModel() { + private val mutableState = MutableStateFlow( + OnboardingState(storagePath = documentStorage.displayPath().getOrNull()), + ) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + fun accept(intent: OnboardingIntent) { + when (intent) { + OnboardingIntent.AdvanceOnboarding -> mutableState.value = state.value.copy( + page = state.value.page + 1, + errorCode = null, + ) + + OnboardingIntent.RetreatOnboarding -> mutableState.value = state.value.copy( + page = (state.value.page - 1).coerceAtLeast(0), + errorCode = null, + ) + + OnboardingIntent.CompleteOnboarding -> completeOnboarding() + + OnboardingIntent.ChooseStorage -> mutableEffects.trySend(OnboardingEffect.PickStorageDirectory) + + OnboardingIntent.OpenPrivacyPolicy -> mutableEffects.trySend(OnboardingEffect.OpenPrivacyPolicy) + + OnboardingIntent.OpenTermsOfUse -> mutableEffects.trySend(OnboardingEffect.OpenTermsOfUse) + + OnboardingIntent.RequestBatteryOptimizationExemption -> + mutableEffects.trySend(OnboardingEffect.RequestBatteryOptimizationExemption) + } + } + + fun handleStorageResult(resultIntent: Intent?) { + val result = resultIntent?.let(documentStorage::persist) ?: AppResult.failure( + AppError(AppErrorCode.STORAGE, "Select onboarding storage directory"), + ) + mutableState.value = state.value.copy( + storagePath = documentStorage.displayPath().getOrNull(), + errorCode = result.errorOrNull()?.code, + ) + } + + private fun completeOnboarding() { + if (documentStorage.hasStorage().getOrNull() != true) { + mutableState.value = state.value.copy(errorCode = AppErrorCode.STORAGE) + return + } + viewModelScope.launch { + val accepted = preferencesRepository.dispatch(AppPreferencesIntent.AcceptPolicy) + if (accepted is AppResult.Failure) { + mutableState.value = state.value.copy(errorCode = accepted.error.code) + return@launch + } + when (val completed = preferencesRepository.dispatch(AppPreferencesIntent.CompleteIntro)) { + is AppResult.Success -> mutableEffects.send(OnboardingEffect.Navigate(MainRoute.RECORD)) + is AppResult.Failure -> mutableState.value = state.value.copy(errorCode = completed.error.code) + } + } + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt index 56fe893..0f4bc74 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt @@ -36,7 +36,7 @@ import com.motionapps.sensorbox.R import com.motionapps.sensorbox.domain.sensors.SensorDescriptor @Composable -fun RecordScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize()) { RecordContent(state, onIntent) SensorSelectionActionBar(state, onIntent, Modifier.align(Alignment.BottomCenter)) @@ -44,27 +44,27 @@ fun RecordScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Mod } @Composable -private fun RecordContent(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { LazyColumn( contentPadding = PaddingValues(start = 20.dp, top = 24.dp, end = 20.dp, bottom = 132.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item { RecordHeader(state) { onIntent(MainIntent.Navigate(MainRoute.SETTINGS)) } } + item { RecordHeader(state) { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) } } item { DeviceSectionHeader(stringResource(R.string.phone_sensors)) } item { SensorSectionHeader(phoneSourceCount(state), state.sensors.size + 1) } item { GpsRow( selected = state.includesGps, - onToggle = { onIntent(MainIntent.ToggleGps) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(null)) }, + onToggle = { onIntent(RecordingIntent.ToggleGps) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } items(state.sensors, key = SensorDescriptor::type) { sensor -> SensorRow( sensor = sensor, selected = sensor.type in state.selectedSensorIds, - onToggle = { onIntent(MainIntent.ToggleSensor(sensor.type)) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(sensor.type)) }, + onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, ) } if (state.isWearConnected) { @@ -73,20 +73,20 @@ private fun RecordContent(state: MainState, onIntent: (MainIntent) -> Unit) { item { GpsRow( selected = state.wearIncludesGps, - onToggle = { onIntent(MainIntent.ToggleWearGps) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(null)) }, + onToggle = { onIntent(RecordingIntent.ToggleWearGps) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } items(state.wearSensors, key = { "wear_${it.type}" }) { sensor -> SensorRow( sensor = sensor, selected = sensor.type in state.selectedWearSensorIds, - onToggle = { onIntent(MainIntent.ToggleWearSensor(sensor.type)) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(sensor.type)) }, + onToggle = { onIntent(RecordingIntent.ToggleWearSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, ) } } - item { MainMessageText(state.message) } + item { RecordingMessageText(state.message) } } } @@ -123,7 +123,7 @@ private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) } @Composable -private fun RecordHeader(state: MainState, onOptions: () -> Unit) { +private fun RecordHeader(state: RecordingState, onOptions: () -> Unit) { val optionsDescription = stringResource(R.string.options) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { SensorBoxScreenHeader( @@ -210,29 +210,34 @@ private fun sensorBorderColor(selected: Boolean) = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant @Composable -private fun SensorSelectionActionBar(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +private fun SensorSelectionActionBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { val sensorCount = selectedSourceCount(state) SensorBoxBottomAction( title = pluralStringResource(R.plurals.sensor_count, sensorCount, sensorCount), description = stringResource(R.string.step_one_of_two), buttonLabel = stringResource(R.string.continue_action), enabled = sensorCount > 0, - onClick = { onIntent(MainIntent.OpenMeasurementSetup) }, + onClick = { onIntent(RecordingIntent.OpenMeasurementSetup) }, modifier = modifier, ) } -private fun phoneSourceCount(state: MainState): Int = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 +private fun phoneSourceCount(state: RecordingState): Int = + state.selectedSensorIds.size + if (state.includesGps) 1 else 0 -private fun wearSourceCount(state: MainState): Int = +private fun wearSourceCount(state: RecordingState): Int = state.selectedWearSensorIds.size + if (state.wearIncludesGps) 1 else 0 -private fun selectedSourceCount(state: MainState): Int = phoneSourceCount(state) + wearSourceCount(state) + +private fun selectedSourceCount(state: RecordingState): Int = phoneSourceCount(state) + wearSourceCount(state) + (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) @Composable -fun MainMessageText(message: MainMessage) { - if (message == MainMessage.NONE) return +fun RecordingMessageText(message: RecordingMessage) { + if (message == RecordingMessage.NONE) return Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { Text( stringResource(messageTextResource(message)), @@ -243,10 +248,10 @@ fun MainMessageText(message: MainMessage) { } @StringRes -private fun messageTextResource(message: MainMessage): Int = when (message) { - MainMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source - MainMessage.STORAGE_REQUIRED -> R.string.message_storage_required - MainMessage.PERMISSION_REQUIRED -> R.string.message_permission_required - MainMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed - MainMessage.NONE -> R.string.app_name +private fun messageTextResource(message: RecordingMessage): Int = when (message) { + RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source + RecordingMessage.STORAGE_REQUIRED -> R.string.message_storage_required + RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required + RecordingMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed + RecordingMessage.NONE -> R.string.app_name } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt new file mode 100644 index 0000000..932411a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt @@ -0,0 +1,155 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorservices.session.MeasurementSessionState + +enum class RecordingMessage { + NONE, + PICK_AT_LEAST_ONE_SOURCE, + STORAGE_REQUIRED, + PERMISSION_REQUIRED, + MEASUREMENT_FAILED, +} + +data class RecordingState( + val sensors: List = emptyList(), + val wearSensors: List = emptyList(), + val detailsSensorType: Int? = null, + val selectedSensorIds: Set = emptySet(), + val includesGps: Boolean = false, + val selectedWearSensorIds: Set = emptySet(), + val wearIncludesGps: Boolean = false, + val customMeasurementName: String = "", + val measurementType: String = "ENDLESS", + 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 storagePath: String? = null, + val preferences: AppPreferences = AppPreferences(), + val session: MeasurementSessionState = MeasurementSessionState.Idle, + val elapsedSeconds: Long = 0, + val isWearConnected: Boolean = false, + val message: RecordingMessage = RecordingMessage.NONE, + val errorCode: AppErrorCode? = null, +) + +sealed interface RecordingIntent { + data class Navigate(val route: MainRoute) : RecordingIntent + data class ToggleSensor(val sensorId: Int) : RecordingIntent + data class ToggleWearSensor(val sensorId: Int) : RecordingIntent + data class OpenSensorDetails(val sensorType: Int?) : RecordingIntent + data object ToggleGps : RecordingIntent + data object ToggleWearGps : RecordingIntent + data object ChooseStorage : RecordingIntent + data object OpenMeasurementSetup : RecordingIntent + data object ReturnToSensorSelection : RecordingIntent + data object StartMeasurement : RecordingIntent + data object StopMeasurement : RecordingIntent + data object ClearMessage : RecordingIntent + data class SetCustomMeasurementName(val value: String) : RecordingIntent + data class SetMeasurementType(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 SetSignificantMotion(val enabled: Boolean) : RecordingIntent + data class AddAnnotation(val text: String) : RecordingIntent + data class SetSamplingPeriod(val index: Int) : RecordingIntent + data class SetLowBatteryRestriction(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 +} + +sealed interface RecordingEffect { + data object PickStorageDirectory : RecordingEffect + data class RequestPermissions(val permissions: Set) : 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) + + private fun reduceNavigation(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { + RecordingIntent.ChooseStorage -> RecordingNext(state, RecordingEffect.PickStorageDirectory) + + is RecordingIntent.Navigate -> RecordingNext(state, RecordingEffect.Navigate(intent.route)) + + is RecordingIntent.OpenSensorDetails -> RecordingNext( + state.copy(detailsSensorType = intent.sensorType), + RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), + ) + + RecordingIntent.OpenMeasurementSetup -> RecordingNext( + state.copy(message = RecordingMessage.NONE), + RecordingEffect.Navigate(MainRoute.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.ToggleWearGps -> RecordingNext(state.copy(wearIncludesGps = !state.wearIncludesGps)) + is RecordingIntent.ToggleSensor -> RecordingNext(state.toggleSensor(intent.sensorId)) + is RecordingIntent.ToggleWearSensor -> RecordingNext(state.toggleWearSensor(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.SetMeasurementType -> RecordingNext(state.copy(measurementType = 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.SetSignificantMotion -> 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.toggleWearSensor(sensorId: Int): RecordingState { + val updated = selectedWearSensorIds.toMutableSet() + if (!updated.add(sensorId)) updated.remove(sensorId) + return copy(selectedWearSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt new file mode 100644 index 0000000..c2ac16a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt @@ -0,0 +1,288 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorbox.domain.sensors.WearSensorCatalogStore +import com.motionapps.sensorservices.session.MeasurementSessionState +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import com.motionapps.wearoslib.connectivity.WearConnection +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +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 workflow: RecordingWorkflowGateway, + private val sessionStore: MeasurementSessionStore, + private val observeWearCapability: ObserveWearCapabilityUseCase, + private val sendWearMessage: SendWearMessageUseCase, + private val wearSensorCatalog: WearSensorCatalogStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(initialState()) + private val mutableEffects = Channel(Channel.BUFFERED) + private var elapsedJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observePreferences() + observeMeasurementSession() + observeWearConnection() + observeWearSensors() + } + + fun accept(intent: RecordingIntent) { + when (intent) { + RecordingIntent.StartMeasurement -> startMeasurement() + + RecordingIntent.StopMeasurement -> stopMeasurement() + + is RecordingIntent.AddAnnotation -> workflow.annotate(intent.text).showFailure() + + is RecordingIntent.SetSamplingPeriod -> updatePreference( + AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), + ) + + is RecordingIntent.SetLowBatteryRestriction -> updatePreference( + AppPreferencesIntent.SetLowBatteryRestriction(intent.enabled), + ) + + is RecordingIntent.SetWakeLock -> updatePreference(AppPreferencesIntent.SetWakeLock(intent.enabled)) + + is RecordingIntent.SetKeepScreenAwake -> updatePreference( + AppPreferencesIntent.SetKeepPhoneDisplayOn(intent.enabled), + ) + + is RecordingIntent.SetGpsInterval -> updatePreference(AppPreferencesIntent.SetGpsInterval(intent.seconds)) + + is RecordingIntent.SetGpsDistance -> updatePreference(AppPreferencesIntent.SetGpsMinDistance(intent.meters)) + + else -> reduce(intent) + } + } + + fun handleStorageResult(resultIntent: Intent?) { + val persisted = workflow.persistStorage(resultIntent) + mutableState.value = state.value.copy( + storagePath = workflow.storagePath(), + message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.STORAGE_REQUIRED, + errorCode = persisted.errorOrNull()?.code, + ) + } + + fun handlePermissionResult() { + val request = state.value.toMeasurementRequest() + val missing = workflow.missingPermissions(request, state.value.includesHeartRate()) + if (missing.isEmpty()) { + startMeasurement() + } else { + showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + } + } + + private fun initialState() = RecordingState( + sensors = workflow.sensors(), + storagePath = workflow.storagePath(), + ) + + private fun observePreferences() { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + result.fold( + onSuccess = { preferences -> + mutableState.value = state.value.copy( + preferences = preferences, + errorCode = null, + ) + }, + onFailure = { error -> + mutableState.value = state.value.copy( + message = RecordingMessage.MEASUREMENT_FAILED, + errorCode = error.code, + ) + }, + ) + } + } + } + + private fun observeMeasurementSession() { + viewModelScope.launch { + sessionStore.state.collect { session -> + onSessionChanged(session) + } + } + } + + private fun onSessionChanged(session: MeasurementSessionState) { + elapsedJob?.cancel() + mutableState.value = state.value.copy(session = session, elapsedSeconds = 0) + if (session is MeasurementSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) + } + + private fun startElapsedTicker(startedAt: Long) { + elapsedJob = viewModelScope.launch { + while (isActive) { + val seconds = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) / 1_000L + mutableState.value = state.value.copy(elapsedSeconds = seconds) + delay(1_000L) + } + } + } + + private fun observeWearConnection() { + viewModelScope.launch { + observeWearCapability(WEAR_APP_CAPABILITY) + .catch { error -> + AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) + emit(WearConnection.Disconnected) + } + .collect { connection -> + mutableState.value = state.value.copy(isWearConnected = connection is WearConnection.Connected) + if (connection is WearConnection.Connected) { + WearCommandCodec.encode(WearCommand.RequestSensorList).suspendFlatMap { payload -> + sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) + } + } else { + wearSensorCatalog.clear() + } + } + } + } + + private fun observeWearSensors() { + viewModelScope.launch { + wearSensorCatalog.sensors.collect { sensors -> + mutableState.value = state.value.copy( + wearSensors = sensors.map { sensor -> + SensorDescriptor( + type = sensor.type, + name = sensor.name, + vendor = sensor.vendor, + isHeartRate = sensor.isHeartRate, + ) + }, + ) + } + } + } + + 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 startMeasurement() { + val request = state.value.toMeasurementRequest() + if (!request.hasAnySource()) { + showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) + return + } + if (!workflow.hasStorage()) { + reduce(RecordingIntent.ChooseStorage) + showMessage(RecordingMessage.STORAGE_REQUIRED, AppErrorCode.STORAGE) + return + } + requestMissingPermissions(request)?.let { + mutableEffects.trySend(RecordingEffect.RequestPermissions(it)) + mutableState.value = state.value.copy(errorCode = AppErrorCode.PERMISSION) + return + } + startForegroundMeasurement(request) + } + + private fun requestMissingPermissions(request: MeasurementRequest): Set? { + val permissions = workflow.missingPermissions(request, state.value.includesHeartRate()) + return permissions.takeIf(Set::isNotEmpty) + } + + private fun startForegroundMeasurement(request: MeasurementRequest) { + viewModelScope.launch { + val result = workflow.start(request) + if (result.isFailure) showFailure(result.errorOrNull()) + } + } + + private fun stopMeasurement() { + viewModelScope.launch { workflow.stop().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: com.motionapps.sensorbox.core.error.AppError?) { + mutableState.value = state.value.copy( + message = RecordingMessage.MEASUREMENT_FAILED, + errorCode = error?.code ?: AppErrorCode.UNKNOWN, + ) + } + + private fun RecordingState.toMeasurementRequest() = MeasurementRequest( + sensorIds = selectedSensorIds, + includesGps = includesGps, + samplingPeriodIndex = preferences.sensorSamplingPeriod, + stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, + useWakeLock = preferences.useWakeLock, + gpsIntervalSeconds = preferences.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, + wearSensorIds = selectedWearSensorIds, + wearIncludesGps = wearIncludesGps, + customName = customMeasurementName, + measurementType = measurementType, + delaySeconds = startDelaySeconds, + durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 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, + ) + + private fun RecordingState.includesHeartRate(): Boolean = sensors.any { + it.isHeartRate && it.type in selectedSensorIds + } + + private fun MeasurementRequest.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || + wearSensorIds.isNotEmpty() || wearIncludesGps || activityRecognition || significantMotion +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt index 6a19ac6..2b96ee7 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt @@ -34,23 +34,48 @@ import androidx.navigation3.ui.NavDisplay import com.motionapps.sensorservices.session.MeasurementSessionState @Composable -fun SensorBoxApp(state: MainState, onIntent: (MainIntent) -> Unit) { - if (!state.hasLoadedPreferences) { +fun SensorBoxApp( + mainState: MainState, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onNavigate: (MainRoute) -> Unit, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, +) { + if (!mainState.hasLoadedPreferences) { Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) return } - if (state.preferences.keepPhoneDisplayOn && state.session is MeasurementSessionState.Running) { - KeepScreenAwake() - } - SensorBoxNavHost(state, onIntent) + if (mainState.keepScreenAwake && mainState.session is MeasurementSessionState.Running) KeepScreenAwake() + SensorBoxNavHost( + mainState = mainState, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onNavigate = onNavigate, + onOnboardingIntent = onOnboardingIntent, + onRecordingIntent = onRecordingIntent, + onSettingsIntent = onSettingsIntent, + ) } @Composable -private fun SensorBoxNavHost(state: MainState, onIntent: (MainIntent) -> Unit) { - val backStack = rememberNavBackStack(state.route) - val displayedRoute = state.displayedRoute() +private fun SensorBoxNavHost( + mainState: MainState, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onNavigate: (MainRoute) -> Unit, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, +) { + val backStack = rememberNavBackStack(mainState.route) + val displayedRoute = mainState.displayedRoute() SynchronizeBackStack(backStack, displayedRoute) - val navigateBack = { navigateBack(state, backStack, onIntent) } + val navigateBack = { navigateBack(mainState, backStack, onNavigate) } NavDisplay( backStack = backStack, @@ -62,8 +87,12 @@ private fun SensorBoxNavHost(state: MainState, onIntent: (MainIntent) -> Unit) { entry { route -> RouteContent( route = route, - state = state, - onIntent = onIntent, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onOnboardingIntent = onOnboardingIntent, + onRecordingIntent = onRecordingIntent, + onSettingsIntent = onSettingsIntent, onBack = navigateBack, ) } @@ -80,10 +109,7 @@ private fun MainState.displayedRoute() = if (session is MeasurementSessionState. @Composable private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute: MainRoute) { LaunchedEffect(displayedRoute) { - if ( - displayedRoute != MainRoute.ACTIVE_MEASUREMENT && - backStack.lastOrNull() == MainRoute.ACTIVE_MEASUREMENT - ) { + if (displayedRoute != MainRoute.ACTIVE_MEASUREMENT && backStack.lastOrNull() == MainRoute.ACTIVE_MEASUREMENT) { backStack.removeLastOrNull() } if (backStack.lastOrNull() != displayedRoute) { @@ -93,11 +119,11 @@ private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute } } -private fun navigateBack(state: MainState, backStack: NavBackStack, onIntent: (MainIntent) -> Unit) { +private fun navigateBack(state: MainState, backStack: NavBackStack, onNavigate: (MainRoute) -> Unit) { if (state.session is MeasurementSessionState.Running) return if (backStack.size > 1) backStack.removeLastOrNull() val destination = backStack.lastOrNull() as? MainRoute ?: MainRoute.RECORD - if (destination != MainRoute.ACTIVE_MEASUREMENT) onIntent(MainIntent.Navigate(destination)) + if (destination != MainRoute.ACTIVE_MEASUREMENT) onNavigate(destination) } private fun MainRoute.isRootDestination() = when (this) { @@ -116,31 +142,42 @@ private fun MainRoute.isRootDestination() = when (this) { } @Composable -private fun RouteContent(route: MainRoute, state: MainState, onIntent: (MainIntent) -> Unit, onBack: () -> Unit) { +private fun RouteContent( + route: MainRoute, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, +) { when (route) { - MainRoute.ONBOARDING -> OnboardingScreen(state, onIntent) + MainRoute.ONBOARDING -> OnboardingScreen(onboardingState, onOnboardingIntent) MainRoute.ACTIVE_MEASUREMENT -> FullScreen { modifier -> - ActiveMeasurementScreen(state, onIntent, modifier) + ActiveMeasurementScreen(recordingState, onRecordingIntent, modifier) } MainRoute.SENSOR_DETAILS -> FullScreen { modifier -> - SensorDetailsScreen(state, onBack, { onIntent(MainIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, modifier) + SensorDetailsScreen( + recordingState, + onBack, + { onRecordingIntent(RecordingIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, + modifier, + ) } MainRoute.SENSOR_PREVIEW -> FullScreen { modifier -> - SensorPreviewScreen(state = state, onBack = onBack, modifier = modifier) + SensorPreviewScreen(state = recordingState, onBack = onBack, modifier = modifier) } MainRoute.SETUP -> FullScreen { modifier -> MeasurementSetupScreen( - state = state, - onIntent = onIntent, + state = recordingState, + onIntent = onRecordingIntent, modifier = modifier, - onBack = { - onIntent(MainIntent.ReturnToSensorSelection) - onBack() - }, + onBack = { onRecordingIntent(RecordingIntent.ReturnToSensorSelection) }, ) } @@ -148,10 +185,17 @@ private fun RouteContent(route: MainRoute, state: MainState, onIntent: (MainInte OpenSourceLicensesScreen(onBack = onBack, modifier = modifier) } - MainRoute.RECORD -> FullScreen { modifier -> RecordScreen(state, onIntent, modifier) } + MainRoute.RECORD -> FullScreen { modifier -> + RecordScreen(recordingState, onRecordingIntent, modifier) + } MainRoute.SETTINGS -> FullScreen { modifier -> - SettingsScreen(state = state, onIntent = onIntent, modifier = modifier, onBack = onBack) + SettingsScreen( + state = settingsState, + onIntent = onSettingsIntent, + modifier = modifier, + onBack = onBack, + ) } MainRoute.PRIVACY -> FullScreen { modifier -> PrivacyScreen(onBack, modifier) } @@ -179,11 +223,6 @@ private fun screenFadeInTween() = tween(SCREEN_FADE_IN_MILLIS, easing = F private fun screenFadeOutTween() = tween(SCREEN_FADE_OUT_MILLIS, easing = FastOutSlowInEasing) -private const val SCREEN_TRANSITION_MILLIS = 300 -private const val SCREEN_FADE_IN_MILLIS = 240 -private const val SCREEN_FADE_OUT_MILLIS = 180 -private const val SCREEN_SLIDE_DIVISOR = 5 - @Composable private fun KeepScreenAwake() { val view = LocalView.current @@ -202,13 +241,14 @@ private fun FullScreen(content: @Composable (Modifier) -> Unit) { contentAlignment = Alignment.TopCenter, ) { content( - Modifier - .widthIn(max = ADAPTIVE_CONTENT_MAX_WIDTH) - .fillMaxWidth() - .fillMaxHeight(), + Modifier.widthIn(max = ADAPTIVE_CONTENT_MAX_WIDTH).fillMaxWidth().fillMaxHeight(), ) } } } +private const val SCREEN_TRANSITION_MILLIS = 300 +private const val SCREEN_FADE_IN_MILLIS = 240 +private const val SCREEN_FADE_OUT_MILLIS = 180 +private const val SCREEN_SLIDE_DIVISOR = 5 private val ADAPTIVE_CONTENT_MAX_WIDTH = 840.dp diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt index 1eeb41c..dcf9535 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -36,7 +36,12 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorservices.handlers.GPSHandler @Composable -fun SensorDetailsScreen(state: MainState, onBack: () -> Unit, onPreview: () -> Unit, modifier: Modifier = Modifier) { +fun SensorDetailsScreen( + state: RecordingState, + onBack: () -> Unit, + onPreview: () -> Unit, + modifier: Modifier = Modifier, +) { val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } val canPreview = state.detailsSensorType == null || sensor?.type != Sensor.TYPE_STEP_DETECTOR val title = when { @@ -105,7 +110,7 @@ private fun HardwareSensorDetails(sensor: SensorDescriptor) { } @Composable -private fun GpsDetails(state: MainState) { +private fun GpsDetails(state: RecordingState) { var permissionRevision by remember { mutableStateOf(0) } val permissionRequest = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), @@ -134,7 +139,7 @@ private fun GpsDetails(state: MainState) { } @Composable -private fun GpsDetailRows(details: GpsDetailsState, state: MainState, unavailableValue: String) { +private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unavailableValue: String) { DetailRow( stringResource(R.string.detail_latitude), details.location?.latitude?.toString() ?: unavailableValue, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt index b4682e8..177c4f9 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -50,7 +50,7 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import kotlin.math.max @Composable -fun SensorPreviewScreen(state: MainState, onBack: () -> Unit, modifier: Modifier = Modifier) { +fun SensorPreviewScreen(state: RecordingState, onBack: () -> Unit, modifier: Modifier = Modifier) { val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } val title = when { state.detailsSensorType == null -> stringResource(R.string.gps_preview) @@ -310,7 +310,7 @@ private fun ChartLegend(label: String, color: Color) { } @Composable -private fun GpsPreview(state: MainState) { +private fun GpsPreview(state: RecordingState) { var permissionRevision by remember { mutableIntStateOf(0) } val permissionRequest = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt new file mode 100644 index 0000000..ab6521a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt @@ -0,0 +1,37 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.preferences.AppPreferences + +data class SettingsState( + val preferences: AppPreferences = AppPreferences(), + val diagnosticsText: String? = null, + val errorCode: AppErrorCode? = null, +) + +sealed interface SettingsIntent { + data class SetSamplingPeriod(val index: Int) : SettingsIntent + data class SetLowBatteryRestriction(val enabled: Boolean) : SettingsIntent + data class SetWakeLock(val enabled: Boolean) : SettingsIntent + data class SetKeepScreenAwake(val enabled: Boolean) : SettingsIntent + data class SetGpsInterval(val seconds: Int) : SettingsIntent + data class SetGpsDistance(val meters: Int) : SettingsIntent + data object RequestBatteryOptimizationExemption : SettingsIntent + data object ShareDiagnosticsText : SettingsIntent + data object ShareDiagnosticsFile : SettingsIntent + data object ViewDiagnostics : SettingsIntent + data object CopyDiagnostics : SettingsIntent + data object ClearDiagnostics : SettingsIntent + data object DismissDiagnostics : SettingsIntent + data class Navigate(val route: MainRoute) : SettingsIntent +} + +sealed interface SettingsEffect { + data object RequestBatteryOptimizationExemption : SettingsEffect + data object ShareDiagnosticsText : SettingsEffect + data object ShareDiagnosticsFile : SettingsEffect + data class CopyDiagnosticsText(val text: String) : SettingsEffect + data object DiagnosticsCleared : SettingsEffect + data class DiagnosticsFailed(val code: AppErrorCode) : SettingsEffect + data class Navigate(val route: MainRoute) : SettingsEffect +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt index be3723c..5656660 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt @@ -10,10 +10,14 @@ import androidx.compose.foundation.layout.PaddingValues 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -45,10 +49,10 @@ import com.motionapps.sensorbox.R @Composable fun SettingsScreen( - state: MainState, - onIntent: (MainIntent) -> Unit, + state: SettingsState, + onIntent: (SettingsIntent) -> Unit, modifier: Modifier = Modifier, - onBack: () -> Unit = { onIntent(MainIntent.Navigate(MainRoute.RECORD)) }, + onBack: () -> Unit = { onIntent(SettingsIntent.Navigate(MainRoute.RECORD)) }, ) { val isBatteryOptimizationExempt = rememberBatteryOptimizationExemption() LazyColumn( @@ -59,19 +63,24 @@ fun SettingsScreen( item { SensorBoxTopAppBar(stringResource(R.string.measurement_settings), onBack) } - item { SamplingSetting(state.preferences.sensorSamplingPeriod, onIntent) } + item { + SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + onIntent(SettingsIntent.SetSamplingPeriod(index)) + } + } item { BatteryGuardSetting(state, onIntent) } item { BatteryOptimizationSetting(isBatteryOptimizationExempt, onIntent) } item { CpuWakeLockSetting(state, onIntent) } item { ScreenAwakeSetting(state, onIntent) } item { GpsSettings(state, onIntent) } - item { DiagnosticsSetting(onIntent) } + item { DiagnosticsSetting(state, onIntent) } item { AboutSetting(onIntent) } } } @Composable -private fun DiagnosticsSetting(onIntent: (MainIntent) -> Unit) { +private fun DiagnosticsSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + var confirmClear by rememberSaveable { mutableStateOf(false) } SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.diagnostics_title), style = MaterialTheme.typography.titleMedium) @@ -79,22 +88,85 @@ private fun DiagnosticsSetting(onIntent: (MainIntent) -> Unit) { stringResource(R.string.diagnostics_summary), color = MaterialTheme.colorScheme.onSurfaceVariant, ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_view), + onClick = { onIntent(SettingsIntent.ViewDiagnostics) }, + modifier = Modifier.fillMaxWidth(), + ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_copy), + onClick = { onIntent(SettingsIntent.CopyDiagnostics) }, + modifier = Modifier.fillMaxWidth(), + ) SensorBoxSecondaryButton( label = stringResource(R.string.diagnostics_share_text), - onClick = { onIntent(MainIntent.ShareDiagnosticsText) }, + onClick = { onIntent(SettingsIntent.ShareDiagnosticsText) }, modifier = Modifier.fillMaxWidth(), ) SensorBoxSecondaryButton( label = stringResource(R.string.diagnostics_share_file), - onClick = { onIntent(MainIntent.ShareDiagnosticsFile) }, + onClick = { onIntent(SettingsIntent.ShareDiagnosticsFile) }, + modifier = Modifier.fillMaxWidth(), + ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_clear), + onClick = { confirmClear = true }, modifier = Modifier.fillMaxWidth(), ) } } + + state.diagnosticsText?.let { diagnostics -> + AlertDialog( + onDismissRequest = { onIntent(SettingsIntent.DismissDiagnostics) }, + title = { Text(stringResource(R.string.diagnostics_title)) }, + text = { + SelectionContainer { + Text( + text = diagnostics, + modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + ) + } + }, + confirmButton = { + TextButton(onClick = { onIntent(SettingsIntent.CopyDiagnostics) }) { + Text(stringResource(R.string.diagnostics_copy)) + } + }, + dismissButton = { + TextButton(onClick = { onIntent(SettingsIntent.DismissDiagnostics) }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (confirmClear) { + AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text(stringResource(R.string.diagnostics_clear)) }, + text = { Text(stringResource(R.string.diagnostics_clear_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + confirmClear = false + onIntent(SettingsIntent.ClearDiagnostics) + }, + ) { + Text(stringResource(R.string.diagnostics_clear)) + } + }, + dismissButton = { + TextButton(onClick = { confirmClear = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } } @Composable -private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { NumberPickerSetting( stringResource(R.string.gps_interval), @@ -106,7 +178,7 @@ private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { ), 1, 3_600, - ) { onIntent(MainIntent.SetGpsInterval(it)) } + ) { onIntent(SettingsIntent.SetGpsInterval(it)) } NumberPickerSetting( stringResource(R.string.gps_minimum_distance), state.preferences.gpsMinDistanceMeters, @@ -117,7 +189,7 @@ private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { ), 0, 10_000, - ) { onIntent(MainIntent.SetGpsDistance(it)) } + ) { onIntent(SettingsIntent.SetGpsDistance(it)) } } } @@ -189,34 +261,34 @@ private fun NumberPickerSetting( } @Composable -private fun BatteryGuardSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_settings_description), checked = state.preferences.restrictMeasurementOnLowBattery, - ) { onIntent(MainIntent.SetLowBatteryRestriction(it)) } + ) { onIntent(SettingsIntent.SetLowBatteryRestriction(it)) } } @Composable -private fun CpuWakeLockSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun CpuWakeLockSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_settings_description), checked = state.preferences.useWakeLock, - ) { onIntent(MainIntent.SetWakeLock(it)) } + ) { onIntent(SettingsIntent.SetWakeLock(it)) } } @Composable -private fun ScreenAwakeSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_settings_description), checked = state.preferences.keepPhoneDisplayOn, - ) { onIntent(MainIntent.SetKeepScreenAwake(it)) } + ) { onIntent(SettingsIntent.SetKeepScreenAwake(it)) } } @Composable -private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) -> Unit) { +private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (SettingsIntent) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.battery_optimization), style = MaterialTheme.typography.titleMedium) @@ -229,7 +301,7 @@ private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) if (!isExempt) { SensorBoxSecondaryButton( label = stringResource(R.string.exclude_from_battery_saving), - onClick = { onIntent(MainIntent.RequestBatteryOptimizationExemption) }, + onClick = { onIntent(SettingsIntent.RequestBatteryOptimizationExemption) }, modifier = Modifier.fillMaxWidth(), ) } @@ -238,7 +310,7 @@ private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) } @Composable -private fun AboutSetting(onIntent: (MainIntent) -> Unit) { +private fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { var showAboutDialog by rememberSaveable { mutableStateOf(false) } SensorBoxPanel { @@ -261,11 +333,11 @@ private fun AboutSetting(onIntent: (MainIntent) -> Unit) { onDismiss = { showAboutDialog = false }, onPrivacy = { showAboutDialog = false - onIntent(MainIntent.Navigate(MainRoute.PRIVACY)) + onIntent(SettingsIntent.Navigate(MainRoute.PRIVACY)) }, onLicenses = { showAboutDialog = false - onIntent(MainIntent.Navigate(MainRoute.LICENSES)) + onIntent(SettingsIntent.Navigate(MainRoute.LICENSES)) }, ) } @@ -291,7 +363,7 @@ private fun Context.isBatteryOptimizationExempt(): Boolean = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) @Composable -fun SamplingSetting(selected: Int, onIntent: (MainIntent) -> Unit) { +fun SamplingSetting(selected: Int, onSamplingPeriod: (Int) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.sensor_sampling), style = MaterialTheme.typography.titleMedium) @@ -307,7 +379,7 @@ fun SamplingSetting(selected: Int, onIntent: (MainIntent) -> Unit) { R.string.sampling_normal, ).forEachIndexed { index, label -> SamplingChip(stringResource(label), selected == index) { - onIntent(MainIntent.SetSamplingPeriod(index)) + onSamplingPeriod(index) } } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt new file mode 100644 index 0000000..c328990 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt @@ -0,0 +1,138 @@ +package com.motionapps.sensorbox.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.di.IoDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val diagnosticsStore: DiagnosticsStore, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val mutableState = MutableStateFlow(SettingsState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy( + preferences = result.value, + errorCode = null, + ) + + is AppResult.Failure -> mutableState.value = state.value.copy(errorCode = result.error.code) + } + } + } + } + + fun accept(intent: SettingsIntent) { + intent.toPreferencesIntent()?.let { + update(it) + return + } + when (intent) { + SettingsIntent.RequestBatteryOptimizationExemption -> + mutableEffects.trySend(SettingsEffect.RequestBatteryOptimizationExemption) + + SettingsIntent.ShareDiagnosticsText -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsText) + + SettingsIntent.ShareDiagnosticsFile -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsFile) + + SettingsIntent.ViewDiagnostics -> viewDiagnostics() + + SettingsIntent.CopyDiagnostics -> copyDiagnostics() + + SettingsIntent.ClearDiagnostics -> clearDiagnostics() + + SettingsIntent.DismissDiagnostics -> mutableState.value = state.value.copy(diagnosticsText = null) + + is SettingsIntent.Navigate -> mutableEffects.trySend(SettingsEffect.Navigate(intent.route)) + + is SettingsIntent.SetSamplingPeriod, + is SettingsIntent.SetLowBatteryRestriction, + is SettingsIntent.SetWakeLock, + is SettingsIntent.SetKeepScreenAwake, + is SettingsIntent.SetGpsInterval, + is SettingsIntent.SetGpsDistance, + -> Unit + } + } + + private fun SettingsIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { + is SettingsIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) + is SettingsIntent.SetLowBatteryRestriction -> AppPreferencesIntent.SetLowBatteryRestriction(enabled) + is SettingsIntent.SetWakeLock -> AppPreferencesIntent.SetWakeLock(enabled) + is SettingsIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) + is SettingsIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) + is SettingsIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) + else -> null + } + + private fun update(intent: AppPreferencesIntent) { + viewModelScope.launch { + preferencesRepository.dispatch(intent).onFailure { error -> + mutableState.value = state.value.copy(errorCode = error.code) + } + } + } + + private fun viewDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.readText()) { + is AppResult.Success -> mutableState.value = state.value.copy( + diagnosticsText = result.value, + errorCode = null, + ) + + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private fun copyDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.readText()) { + is AppResult.Success -> mutableEffects.send(SettingsEffect.CopyDiagnosticsText(result.value)) + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private fun clearDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.clear()) { + is AppResult.Success -> { + mutableState.value = state.value.copy(diagnosticsText = null, errorCode = null) + mutableEffects.send(SettingsEffect.DiagnosticsCleared) + } + + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private suspend fun fail(code: AppErrorCode) { + mutableState.value = state.value.copy(errorCode = code) + mutableEffects.send(SettingsEffect.DiagnosticsFailed(code)) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77d5c1f..42222f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,5 +228,12 @@ Share diagnostics Send as text Send as file + View logs + Copy logs + Clear logs + Delete both retained diagnostic log files? This cannot be undone. + Diagnostics copied. + Diagnostics cleared. + The diagnostics action failed. Diagnostics could not be shared. The error was saved for a later attempt. diff --git a/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt b/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt index 5774387..34a824a 100644 --- a/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt @@ -1,9 +1,9 @@ package com.motionapps.sensorbox -import java.io.File -import javax.xml.parsers.DocumentBuilderFactory import org.junit.Assert.assertEquals import org.junit.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory class PolicyLinksTest { @Test diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt new file mode 100644 index 0000000..f2bd197 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt @@ -0,0 +1,223 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController +import com.motionapps.sensorbox.domain.measurement.PreparedPhoneRecording +import com.motionapps.sensorservices.intent.MeasurementLaunchRequest +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import com.motionapps.wearoslib.connectivity.WearConnectionRepository +import com.motionapps.wearoslib.connectivity.WearNode +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import com.motionapps.wearoslib.protocol.WearSessionCommand +import com.motionapps.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class PairedRecordingCoordinatorTest { + @Test + fun `Given Wear rejects prepare When start is requested Then local preparation is aborted`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.ack( + command.sessionId, + WearSessionCommand.PREPARE, + WearAcknowledgementOutcome.REJECTED, + AppErrorCode.PERMISSION, + ) + + is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + + else -> Unit + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.abortCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) + assertEquals(0, fixture.local.commitCalls) + } + + @Test + fun `Given no prepare ack When start times out Then three sends and both sides abort`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + if (command is WearCommand.AbortRecording) { + fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertEquals(AppErrorCode.TIMEOUT, result.errorOrNull()?.code) + assertEquals(3, fixture.repository.commands.count { it is WearCommand.PrepareRecording }) + assertEquals(1, fixture.local.abortCalls) + } + + @Test + fun `Given commit is rejected When local commit succeeded Then both devices are compensated`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) + + is WearCommand.CommitRecording -> fixture.ack( + command.sessionId, + WearSessionCommand.COMMIT, + WearAcknowledgementOutcome.FAILED, + AppErrorCode.MEASUREMENT, + ) + + is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + + else -> Unit + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.commitCalls) + assertEquals(1, fixture.local.abortCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) + } + + @Test + fun `Given local stop fails When paired recording stops Then remote stop is still attempted`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) + is WearCommand.CommitRecording -> fixture.succeed(command.sessionId, WearSessionCommand.COMMIT) + is WearCommand.StopRecording -> fixture.succeed(command.sessionId, WearSessionCommand.STOP) + else -> Unit + } + } + assertTrue(fixture.coordinator.start(pairedRequest()).isSuccess) + fixture.local.stopResult = failure("Stop local recording") + + val result = fixture.coordinator.stop() + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.stopCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.StopRecording }) + } + + private fun pairedRequest() = MeasurementRequest( + sensorIds = setOf(1), + includesGps = false, + samplingPeriodIndex = 0, + stopOnLowBattery = true, + useWakeLock = true, + gpsIntervalSeconds = 10, + gpsMinDistanceMeters = 20, + wearSensorIds = setOf(21), + ) + + private class Fixture { + val inbox = WearAcknowledgementInbox() + val repository = InteractiveRepository() + val local = FakePhoneRecordingController() + val coordinator = PairedRecordingCoordinator( + localController = local, + sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + acknowledgementInbox = inbox, + sessionIdFactory = RecordingSessionIdFactory(), + diagnosticLogger = DiagnosticLogger { }, + ) + + fun succeed(sessionId: String, command: WearSessionCommand) { + ack(sessionId, command, WearAcknowledgementOutcome.SUCCEEDED) + } + + fun ack( + sessionId: String, + command: WearSessionCommand, + outcome: WearAcknowledgementOutcome, + errorCode: AppErrorCode? = null, + ) { + inbox.publish(WearCommand.Acknowledgement(sessionId, command, outcome, errorCode)) + } + } +} + +private class InteractiveRepository : WearConnectionRepository { + val commands = mutableListOf() + var onCommand: (WearCommand) -> Unit = { } + + override fun observeCapability(capability: String): Flow = + emptyFlow() + + override suspend fun findNode(capability: String): WearNode? = WearNode("wear", "Wear", isNearby = true) + + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + val command = WearCommandCodec.decode(payload).getOrThrow() + commands += command + onCommand(command) + return AppResult.success(Unit) + } +} + +private class FakePhoneRecordingController : PhoneRecordingController { + var commitCalls = 0 + var abortCalls = 0 + var stopCalls = 0 + var stopResult: AppResult = AppResult.success(Unit) + + override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = + AppResult.success( + PreparedPhoneRecording( + sessionId = sessionId, + launchRequest = MeasurementLaunchRequest( + sessionId = sessionId, + folderName = "fixture", + useInternalStorage = false, + sensorIds = request.sensorIds, + sensorSamplingPeriod = 0, + includesGps = request.includesGps, + stopOnLowBattery = request.stopOnLowBattery, + useWakeLock = request.useWakeLock, + gpsIntervalSeconds = request.gpsIntervalSeconds, + gpsMinDistanceMeters = request.gpsMinDistanceMeters, + ), + ), + ) + + override fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult { + commitCalls += 1 + return AppResult.success(Unit) + } + + override fun abort(sessionId: String): AppResult { + abortCalls += 1 + return AppResult.success(Unit) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + stopCalls += 1 + return stopResult + } + + override fun stopAny(reason: WearStopReason): AppResult = stopResult + + override fun annotate(text: String, timestampMillis: Long): AppResult = AppResult.success(Unit) +} + +private fun failure(operation: String): AppResult = AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, operation), +) diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt new file mode 100644 index 0000000..207dc92 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt @@ -0,0 +1,51 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhoneWearMessageDispatcherTest { + @Test + fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest { + val commands = mutableListOf() + val dispatcher = PhoneWearMessageDispatcher( + commandHandler = PhoneWearCommandPolicy { command -> + commands += command + AppResult.success(Unit) + }, + diagnosticLogger = DiagnosticLogger { }, + ) + val payload = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() + + val result = dispatcher.dispatch(PHONE_MESSAGE_PATH, payload) + + assertTrue(result.isSuccess) + assertEquals(listOf(WearCommand.LaunchPhone), commands) + } + + @Test + fun `Given a protocol v1 payload When dispatched Then command policy is not called`() = runTest { + var calls = 0 + val dispatcher = PhoneWearMessageDispatcher( + commandHandler = PhoneWearCommandPolicy { + calls += 1 + AppResult.success(Unit) + }, + diagnosticLogger = DiagnosticLogger { }, + ) + + val result = dispatcher.dispatch( + PHONE_MESSAGE_PATH, + byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1), + ) + + assertTrue(result.isFailure) + assertEquals(0, calls) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt new file mode 100644 index 0000000..b0cbf75 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -0,0 +1,168 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.testing.FakeAppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorbox.domain.sensors.WearSensorCatalogStore +import com.motionapps.sensorbox.testing.MainDispatcherRule +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.wearoslib.connectivity.FakeWearConnectionRepository +import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +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.Rule +import org.junit.Test +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class FeatureViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given onboarding storage When completed Then state and navigation effect are owned by onboarding`() = runTest { + val viewModel = OnboardingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + documentStorage = FakeDocumentStorage(hasStorage = true), + ) + val effect = async { viewModel.effects.first() } + + viewModel.accept(OnboardingIntent.AdvanceOnboarding) + viewModel.accept(OnboardingIntent.CompleteOnboarding) + advanceUntilIdle() + + assertEquals(1, viewModel.state.value.page) + assertEquals(OnboardingEffect.Navigate(MainRoute.RECORD), effect.await()) + } + + @Test + fun `Given onboarding has no storage When completed Then storage error code is exposed`() = runTest { + val viewModel = OnboardingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + documentStorage = FakeDocumentStorage(hasStorage = false), + ) + + viewModel.accept(OnboardingIntent.CompleteOnboarding) + + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + } + + @Test + fun `Given settings action When sampling changes Then settings owns updated preference state`() = runTest { + val viewModel = SettingsViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + diagnosticsStore = FakeDiagnosticsStore(), + ioDispatcher = mainDispatcherRule.dispatcher, + ) + advanceUntilIdle() + + viewModel.accept(SettingsIntent.SetSamplingPeriod(3)) + advanceUntilIdle() + + assertEquals(3, viewModel.state.value.preferences.sensorSamplingPeriod) + } + + @Test + fun `Given diagnostics read failure When viewed Then settings emits the stable error code`() = runTest { + val diagnostics = FakeDiagnosticsStore( + readResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Read fixture diagnostics")), + ) + val viewModel = SettingsViewModel( + FakeAppPreferencesRepository(), + diagnostics, + mainDispatcherRule.dispatcher, + ) + val effect = async { viewModel.effects.first() } + + viewModel.accept(SettingsIntent.ViewDiagnostics) + advanceUntilIdle() + + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + assertEquals(SettingsEffect.DiagnosticsFailed(AppErrorCode.STORAGE), effect.await()) + } + + @Test + fun `Given no selected source When recording starts Then recording exposes validation code`() = runTest { + val viewModel = recordingViewModel(FakeRecordingWorkflow()) + advanceUntilIdle() + + viewModel.accept(RecordingIntent.StartMeasurement) + + assertEquals(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, viewModel.state.value.message) + assertEquals(AppErrorCode.VALIDATION, viewModel.state.value.errorCode) + } + + @Test + fun `Given storage action When chosen Then recording emits only its picker effect`() = runTest { + val viewModel = recordingViewModel(FakeRecordingWorkflow()) + val effect = async { viewModel.effects.first() } + + viewModel.accept(RecordingIntent.ChooseStorage) + + assertEquals(RecordingEffect.PickStorageDirectory, effect.await()) + } + + private fun recordingViewModel(workflow: RecordingWorkflowGateway): RecordingViewModel { + val repository = FakeWearConnectionRepository() + return RecordingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + workflow = workflow, + sessionStore = MeasurementSessionStore(), + observeWearCapability = ObserveWearCapabilityUseCase(repository), + sendWearMessage = SendWearMessageUseCase(repository), + wearSensorCatalog = WearSensorCatalogStore(), + ) + } +} + +private class FakeDocumentStorage(private val hasStorage: Boolean) : DocumentStorageGateway { + override fun hasStorage(): AppResult = AppResult.success(hasStorage) + + override fun displayPath(): AppResult = AppResult.success(if (hasStorage) "fixture" else null) + + override fun persist(resultIntent: Intent): AppResult = AppResult.success(Unit) +} + +private class FakeDiagnosticsStore( + private val readResult: AppResult = AppResult.success("fixture diagnostics"), +) : DiagnosticsStore { + override fun readText(): AppResult = readResult + + override fun exportFile(): AppResult = AppResult.failure( + AppError(AppErrorCode.STORAGE, "Export fixture diagnostics"), + ) + + override fun clear(): AppResult = AppResult.success(Unit) +} + +private class FakeRecordingWorkflow : RecordingWorkflowGateway { + var startResult: AppResult = AppResult.success(Unit) + + override fun sensors(): List = emptyList() + + override fun storagePath(): String? = "fixture" + + override fun hasStorage(): Boolean = true + + override fun persistStorage(resultIntent: Intent?): AppResult = AppResult.success(Unit) + + override fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = emptySet() + + override suspend fun start(request: MeasurementRequest): AppResult = startResult + + override suspend fun stop(): AppResult = AppResult.success(Unit) + + override fun annotate(text: String): AppResult = AppResult.success(Unit) +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt deleted file mode 100644 index 67d5338..0000000 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.motionapps.sensorbox.presentation.main - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class MainReducerTest { - @Test - fun `Given an unselected sensor When toggled Then it becomes selected`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleSensor(sensorId = 1)) - - assertTrue(1 in actual.state.selectedSensorIds) - } - - @Test - fun `Given record state When storage is requested Then picker effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ChooseStorage) - - assertEquals(MainEffect.PickStorageDirectory, actual.effect) - } - - @Test - fun `Given GPS disabled When toggled Then GPS becomes enabled`() { - val givenState = MainStateFixtures.state(includesGps = false) - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleGps) - - assertTrue(actual.state.includesGps) - } - - @Test - fun `Given an unselected Wear sensor When toggled Then only Wear selection changes`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleWearSensor(sensorId = 21)) - - assertTrue(21 in actual.state.selectedWearSensorIds) - assertTrue(actual.state.selectedSensorIds.isEmpty()) - } - - @Test - fun `Given endless mode When timed mode is selected Then timing configuration is retained`() { - val givenState = MainStateFixtures.state().copy(durationSeconds = 60) - - val actual = MainReducer.reduce(givenState, MainIntent.SetMeasurementType("TIMED")) - - assertEquals("TIMED", actual.state.measurementType) - assertEquals(60, actual.state.durationSeconds) - } - - @Test - fun `Given selected sensors When setup is opened Then setup route is shown`() { - val givenState = MainStateFixtures.state(selectedSensorIds = setOf(1)) - - val actual = MainReducer.reduce(givenState, MainIntent.OpenMeasurementSetup) - - assertEquals(MainRoute.SETUP, actual.state.route) - } - - @Test - fun `Given a sensor When its information is opened Then details route retains its type`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenSensorDetails(sensorType = 1)) - - assertEquals(MainRoute.SENSOR_DETAILS, actual.state.route) - assertEquals(1, actual.state.detailsSensorType) - } - - @Test - fun `Given measurement setup When returning Then sensor selection is shown`() { - val givenState = MainStateFixtures.state(route = MainRoute.SETUP, selectedSensorIds = setOf(1)) - - val actual = MainReducer.reduce(givenState, MainIntent.ReturnToSensorSelection) - - assertEquals(MainRoute.RECORD, actual.state.route) - } - - @Test - fun `Given later introduction page When going back Then previous page is shown`() { - val givenState = MainStateFixtures.state().copy(onboardingPage = 3) - - val actual = MainReducer.reduce(givenState, MainIntent.RetreatOnboarding) - - assertEquals(2, actual.state.onboardingPage) - } - - @Test - fun `Given policy introduction When privacy is opened Then privacy effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenPrivacyPolicy) - - assertEquals(MainEffect.OpenPrivacyPolicy, actual.effect) - } - - @Test - fun `Given policy introduction When terms are opened Then terms effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenTermsOfUse) - - assertEquals(MainEffect.OpenTermsOfUse, actual.effect) - } - - @Test - fun `Given battery introduction When exemption is requested Then settings effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.RequestBatteryOptimizationExemption) - - assertEquals(MainEffect.RequestBatteryOptimizationExemption, actual.effect) - } -} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt new file mode 100644 index 0000000..0aa4a52 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt @@ -0,0 +1,82 @@ +package com.motionapps.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 storage is requested Then picker effect is emitted`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ChooseStorage) + + assertEquals(RecordingEffect.PickStorageDirectory, 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.ToggleWearSensor(sensorId = 21)) + + assertTrue(21 in actual.state.selectedWearSensorIds) + assertTrue(actual.state.selectedSensorIds.isEmpty()) + } + + @Test + fun `Given endless mode When timed mode is selected Then timing configuration is retained`() { + val givenState = RecordingStateFixtures.state().copy(durationSeconds = 60) + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetMeasurementType("TIMED")) + + assertEquals("TIMED", actual.state.measurementType) + assertEquals(60, 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.OpenMeasurementSetup) + + assertEquals(RecordingEffect.Navigate(MainRoute.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 measurement 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/motionapps/sensorbox/presentation/main/MainStateFixtures.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt similarity index 61% rename from app/src/test/java/com/motionapps/sensorbox/presentation/main/MainStateFixtures.kt rename to app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt index 18f5cad..e79bd86 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainStateFixtures.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt @@ -2,13 +2,8 @@ package com.motionapps.sensorbox.presentation.main import com.motionapps.sensorbox.domain.sensors.SensorDescriptor -object MainStateFixtures { - fun state( - route: MainRoute = MainRoute.RECORD, - selectedSensorIds: Set = emptySet(), - includesGps: Boolean = false, - ) = MainState( - route = route, +object RecordingStateFixtures { + fun state(selectedSensorIds: Set = emptySet(), includesGps: Boolean = false) = RecordingState( sensors = listOf( SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture", isHeartRate = false), ), diff --git a/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt b/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt new file mode 100644 index 0000000..38c9e27 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule(val dispatcher: TestDispatcher = StandardTestDispatcher()) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c953e63..10f9403 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.oss.licenses) apply false } diff --git a/core-common/build.gradle.kts b/core-common/build.gradle.kts new file mode 100644 index 0000000..de002f4 --- /dev/null +++ b/core-common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.detekt) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(libs.coroutines.core) + testImplementation(libs.junit) +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt new file mode 100644 index 0000000..c8d4ac3 --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt @@ -0,0 +1,45 @@ +package com.motionapps.sensorbox.core.error + +data class AppError( + val code: AppErrorCode, + val operation: String, + val diagnosticMessage: String, + val cause: Throwable? = null, + val context: Map = emptyMap(), + val isRetryable: Boolean = false, +) { + constructor(code: AppErrorCode, operation: String) : this( + code = code, + operation = operation, + diagnosticMessage = "$operation failed", + ) + + fun within(parentOperation: String, fallbackCode: AppErrorCode = code): AppError = copy( + code = if (code == AppErrorCode.UNKNOWN) fallbackCode else code, + operation = parentOperation, + diagnosticMessage = "$parentOperation failed during $operation", + context = context + ("failedOperation" to operation), + ) + + companion object { + fun from(code: AppErrorCode, operation: String, cause: Throwable): AppError = AppError( + code = code, + operation = operation, + diagnosticMessage = "$operation failed with ${cause::class.java.simpleName}", + cause = cause, + ) + } +} + +enum class AppErrorCode { + CONNECTIVITY, + EXTERNAL_ACTION, + MEASUREMENT, + PERMISSION, + PREFERENCES, + STORAGE, + VALIDATION, + TIMEOUT, + CONFLICT, + UNKNOWN, +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt new file mode 100644 index 0000000..86a4eaf --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt @@ -0,0 +1,123 @@ +package com.motionapps.sensorbox.core.error + +import kotlinx.coroutines.CancellationException + +sealed interface AppResult { + val isSuccess: Boolean + get() = this is Success + + val isFailure: Boolean + get() = this is Failure + + fun getOrNull(): T? = when (this) { + is Success -> value + is Failure -> null + } + + fun errorOrNull(): AppError? = when (this) { + is Success -> null + is Failure -> error + } + + fun fold(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) { + is Success -> onSuccess(value) + is Failure -> onFailure(error) + } + + fun map(transform: (T) -> R): AppResult = when (this) { + is Success -> success(transform(value)) + is Failure -> this + } + + fun getOrElse(onFailure: (AppError) -> @UnsafeVariance T): T = when (this) { + is Success -> value + is Failure -> onFailure(error) + } + + fun getOrDefault(defaultValue: @UnsafeVariance T): T = when (this) { + is Success -> value + is Failure -> defaultValue + } + + fun getOrThrow(): T = when (this) { + is Success -> value + is Failure -> throw IllegalStateException(error.diagnosticMessage, error.cause) + } + + fun onSuccess(action: (T) -> Unit): AppResult = apply { + if (this is Success) action(value) + } + + fun onFailure(action: (AppError) -> Unit): AppResult = apply { + if (this is Failure) action(error) + } + + data class Success(val value: T) : AppResult + + data class Failure(val error: AppError) : AppResult + + companion object { + fun success(value: T): AppResult = Success(value) + + fun failure(error: AppError): AppResult = Failure(error) + } +} + +inline fun AppResult.flatMap(transform: (T) -> AppResult): AppResult = when (this) { + is AppResult.Success -> transform(value) + is AppResult.Failure -> this +} + +suspend inline fun AppResult.suspendFlatMap( + crossinline transform: suspend (T) -> AppResult, +): AppResult = when (this) { + is AppResult.Success -> transform(value) + is AppResult.Failure -> this +} + +@Suppress("TooGenericExceptionCaught") +inline fun appResult(code: AppErrorCode, operation: String, block: () -> T): AppResult = try { + AppResult.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + AppResult.failure(AppError.from(code, operation, error)) +} + +@Suppress("TooGenericExceptionCaught") +suspend inline fun suspendAppResult( + code: AppErrorCode, + operation: String, + crossinline block: suspend () -> T, +): AppResult = try { + AppResult.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + AppResult.failure(AppError.from(code, operation, error)) +} + +fun AppResult.withAppError(code: AppErrorCode, operation: String): AppResult = when (this) { + is AppResult.Success -> this + is AppResult.Failure -> AppResult.failure(error.within(operation, code)) +} + +fun Iterable>.combineAppResults(code: AppErrorCode, operation: String): AppResult { + val errors = mapNotNull(AppResult<*>::errorOrNull) + if (errors.isEmpty()) return AppResult.success(Unit) + val causes = errors.mapNotNull(AppError::cause) + val firstCause = causes.firstOrNull() + causes.drop(1).forEach { cause -> firstCause?.addSuppressed(cause) } + return AppResult.failure( + AppError( + code = errors.first().code.takeUnless { it == AppErrorCode.UNKNOWN } ?: code, + operation = operation, + diagnosticMessage = "$operation failed in ${errors.size} operation(s)", + cause = firstCause, + context = mapOf( + "failureCount" to errors.size.toString(), + "failedOperations" to errors.joinToString(",") { it.operation }, + ), + ), + ) +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt new file mode 100644 index 0000000..338d26a --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt @@ -0,0 +1,41 @@ +package com.motionapps.sensorbox.core.error + +import java.io.File + +enum class DiagnosticSeverity { + INFO, + WARNING, + ERROR, + FATAL, +} + +data class DiagnosticEvent( + val severity: DiagnosticSeverity, + val code: AppErrorCode, + val operation: String, + val diagnosticMessage: String, + val cause: Throwable? = null, + val context: Map = emptyMap(), +) + +fun AppError.toDiagnosticEvent(severity: DiagnosticSeverity = DiagnosticSeverity.ERROR): DiagnosticEvent = + DiagnosticEvent( + severity = severity, + code = code, + operation = operation, + diagnosticMessage = diagnosticMessage, + cause = cause, + context = context, + ) + +fun interface DiagnosticLogger { + fun record(event: DiagnosticEvent) +} + +interface DiagnosticsStore { + fun readText(): AppResult + + fun exportFile(): AppResult + + fun clear(): AppResult +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 16d4218..74da485 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -23,6 +23,8 @@ android { } dependencies { + api(project(":core-common")) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.documentfile) implementation(libs.coroutines.core) diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt deleted file mode 100644 index bed03b5..0000000 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.motionapps.sensorbox.core.error - -import android.content.Context -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -object AppDiagnostics { - private val lock = Any() - private val pending = ArrayDeque() - - @Volatile - private var applicationContext: Context? = null - - @Volatile - private var uncaughtHandlerInstalled = false - - fun install(context: Context) { - synchronized(lock) { - applicationContext = context.applicationContext - installUncaughtExceptionHandler() - val queued = pending.toList() - pending.clear() - queued.forEach(::appendSafely) - } - } - - private fun installUncaughtExceptionHandler() { - if (uncaughtHandlerInstalled) return - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, error -> - AppError.from(AppError.Kind.UNKNOWN, "Uncaught exception on ${thread.name}", error) - previous?.uncaughtException(thread, error) - } - uncaughtHandlerInstalled = true - } - - fun record(error: AppError) { - val entry = error.toDiagnosticEntry() - synchronized(lock) { - if (applicationContext == null) { - if (pending.size == MAX_PENDING_ENTRIES) pending.removeFirst() - pending.addLast(entry) - } else { - appendSafely(entry) - } - } - } - - fun readText(): Result = appResult(AppError.Kind.STORAGE, "Read diagnostics") { - synchronized(lock) { - val file = diagnosticsFile() - if (file.exists()) file.readText() else NO_DIAGNOSTICS - } - } - - fun exportFile(): Result = appResult(AppError.Kind.STORAGE, "Export diagnostics") { - synchronized(lock) { - diagnosticsFile().also { file -> - file.parentFile?.mkdirs() - if (!file.exists()) file.writeText(NO_DIAGNOSTICS) - } - } - } - - fun clear(): Result = appResult(AppError.Kind.STORAGE, "Clear diagnostics") { - synchronized(lock) { - val file = diagnosticsFile() - check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } - } - } - - private fun appendSafely(entry: String) { - try { - val file = diagnosticsFile() - file.parentFile?.mkdirs() - if (file.length() + entry.length > MAX_FILE_BYTES) rotate(file) - file.appendText(entry) - } catch (_: Throwable) { - // Diagnostics must never become a second failure source. - } - } - - private fun rotate(file: File) { - val previous = File(file.parentFile, PREVIOUS_FILE_NAME) - if (previous.exists()) previous.delete() - if (file.exists()) file.renameTo(previous) - } - - private fun diagnosticsFile(): File { - val context = checkNotNull(applicationContext) { "Diagnostics are not initialized" } - return File(File(context.filesDir, DIRECTORY_NAME), FILE_NAME) - } - - private fun AppError.toDiagnosticEntry(): String = buildString { - val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) - append(timestamp).append(" | ").append(kind).append(" | ").append(operation).appendLine() - append(stackTraceToString().take(MAX_ENTRY_CHARS)).appendLine() - appendLine(ENTRY_SEPARATOR) - } - - private const val DIRECTORY_NAME = "diagnostics" - private const val FILE_NAME = "sensorbox-diagnostics.txt" - private const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt" - private const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - private const val ENTRY_SEPARATOR = "---" - private const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n" - private const val MAX_PENDING_ENTRIES = 20 - private const val MAX_ENTRY_CHARS = 32_000 - private const val MAX_FILE_BYTES = 1_000_000L -} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt deleted file mode 100644 index 5fd0da9..0000000 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.motionapps.sensorbox.core.error - -import kotlinx.coroutines.CancellationException - -class AppError(val kind: Kind, val operation: String, cause: Throwable? = null) : - Exception(message(operation, cause), cause) { - init { - AppDiagnostics.record(this) - } - - enum class Kind { - CONNECTIVITY, - EXTERNAL_ACTION, - MEASUREMENT, - PERMISSION, - PREFERENCES, - STORAGE, - UNKNOWN, - } - - companion object { - fun from(kind: Kind, operation: String, cause: Throwable): AppError = - cause as? AppError ?: AppError(kind, operation, cause) - - private fun message(operation: String, cause: Throwable?): String = - cause?.message?.takeIf(String::isNotBlank)?.let { "$operation: $it" } ?: "$operation failed" - } -} - -@Suppress("TooGenericExceptionCaught") -inline fun appResult(kind: AppError.Kind, operation: String, block: () -> T): Result = try { - Result.success(block()) -} catch (error: CancellationException) { - throw error -} catch (error: Throwable) { - Result.failure(AppError.from(kind, operation, error)) -} - -@Suppress("TooGenericExceptionCaught") -suspend inline fun suspendAppResult( - kind: AppError.Kind, - operation: String, - crossinline block: suspend () -> T, -): Result = try { - Result.success(block()) -} catch (error: CancellationException) { - throw error -} catch (error: Throwable) { - Result.failure(AppError.from(kind, operation, error)) -} - -fun Result.withAppError(kind: AppError.Kind, operation: String): Result = fold( - onSuccess = Result.Companion::success, - onFailure = { Result.failure(AppError.from(kind, operation, it)) }, -) - -inline fun Result.flatMap(transform: (T) -> Result): Result = fold( - onSuccess = transform, - onFailure = Result.Companion::failure, -) - -suspend inline fun Result.suspendFlatMap(crossinline transform: suspend (T) -> Result): Result = fold( - onSuccess = { transform(it) }, - onFailure = Result.Companion::failure, -) - -fun Iterable>.combineAppResults(kind: AppError.Kind, operation: String): Result { - val failures = mapNotNull(Result<*>::exceptionOrNull) - if (failures.isEmpty()) return Result.success(Unit) - val first = failures.first() - failures.drop(1).forEach(first::addSuppressed) - return Result.failure(AppError.from(kind, operation, first)) -} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt new file mode 100644 index 0000000..fc46960 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt @@ -0,0 +1,161 @@ +package com.motionapps.sensorbox.core.error + +import android.content.Context +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class DiagnosticMetadata( + val appVersion: String, + val buildType: String, + val deviceModel: String, + val androidVersion: String, + val processName: String, +) + +class FileDiagnostics internal constructor( + private val diagnosticsDirectory: File, + private val metadata: DiagnosticMetadata, +) : DiagnosticLogger, + DiagnosticsStore { + private val lock = Any() + + constructor(context: Context, metadata: DiagnosticMetadata) : this( + diagnosticsDirectory = File(context.filesDir, DIRECTORY_NAME), + metadata = metadata, + ) + + @Volatile + private var uncaughtHandlerInstalled = false + + override fun record(event: DiagnosticEvent) { + val entry = event.toDiagnosticEntry().take(MAX_ENTRY_CHARS) + synchronized(lock) { + appendSafely(entry) + } + } + + fun installUncaughtExceptionHandler() { + if (uncaughtHandlerInstalled) return + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + record( + DiagnosticEvent( + severity = DiagnosticSeverity.FATAL, + code = AppErrorCode.UNKNOWN, + operation = "Uncaught exception", + diagnosticMessage = "Uncaught ${error::class.java.simpleName}", + cause = error, + context = mapOf("threadName" to thread.name), + ), + ) + previous?.uncaughtException(thread, error) + } + uncaughtHandlerInstalled = true + } + + override fun readText(): AppResult = appResult(AppErrorCode.STORAGE, "Read diagnostics") { + synchronized(lock) { + val files = listOf(previousFile(), currentFile()).filter(File::exists) + if (files.isEmpty()) NO_DIAGNOSTICS else files.joinToString(separator = "") { it.readText() } + } + } + + override fun exportFile(): AppResult = readText().flatMap { text -> + appResult(AppErrorCode.STORAGE, "Export diagnostics") { + synchronized(lock) { + diagnosticsDirectory.mkdirs() + exportFilePath().apply { writeText(text) } + } + } + } + + override fun clear(): AppResult = appResult(AppErrorCode.STORAGE, "Clear diagnostics") { + synchronized(lock) { + listOf(currentFile(), previousFile(), exportFilePath()).forEach { file -> + check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } + } + } + } + + private fun appendSafely(entry: String) { + try { + diagnosticsDirectory.mkdirs() + val file = currentFile() + if (file.length() + entry.toByteArray().size > MAX_FILE_BYTES) rotate(file) + file.appendText(entry) + } catch (_: Throwable) { + // A diagnostics write must never become an application failure. + } + } + + private fun rotate(file: File) { + val previous = previousFile() + if (previous.exists()) previous.delete() + if (file.exists()) file.renameTo(previous) + } + + private fun DiagnosticEvent.toDiagnosticEntry(): String = buildString { + val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) + append(timestamp).append(" | ").append(severity).append(" | ").append(code).append(" | ") + .append(operation.safeText()).appendLine() + append("message=").append(diagnosticMessage.safeText()).appendLine() + append("appVersion=").append(metadata.appVersion.safeText()).appendLine() + append("buildType=").append(metadata.buildType.safeText()).appendLine() + append("deviceModel=").append(metadata.deviceModel.safeText()).appendLine() + append("androidVersion=").append(metadata.androidVersion.safeText()).appendLine() + append("process=").append(metadata.processName.safeText()).appendLine() + context.filterKeys(SAFE_CONTEXT_KEYS::contains).toSortedMap().forEach { (key, value) -> + append(key).append('=').append(value.safeText()).appendLine() + } + cause?.let { error -> + append("exception=").append(error::class.java.name).appendLine() + error.stackTrace.take(MAX_STACK_FRAMES).forEach { frame -> + append("at ").append(frame.className).append('.').append(frame.methodName) + .append('(').append(frame.fileName?.substringAfterLast('/')?.safeText() ?: "Unknown") + .append(':').append(frame.lineNumber).appendLine(")") + } + } + appendLine(ENTRY_SEPARATOR) + } + + private fun String.safeText(): String = replace('\n', ' ').replace('\r', ' ').take(MAX_FIELD_CHARS) + + private fun currentFile(): File = File(diagnosticsDirectory, FILE_NAME) + + private fun previousFile(): File = File(diagnosticsDirectory, PREVIOUS_FILE_NAME) + + private fun exportFilePath(): File = File(diagnosticsDirectory, EXPORT_FILE_NAME) + + private companion object { + const val DIRECTORY_NAME = "diagnostics" + const val FILE_NAME = "sensorbox-diagnostics.txt" + const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt" + const val EXPORT_FILE_NAME = "sensorbox-diagnostics-export.txt" + const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + const val ENTRY_SEPARATOR = "---" + const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n" + const val MAX_ENTRY_CHARS = 32_000 + const val MAX_FIELD_CHARS = 512 + const val MAX_STACK_FRAMES = 80 + const val MAX_FILE_BYTES = 1_000_000L + val SAFE_CONTEXT_KEYS = setOf( + "androidVersion", + "buildType", + "durationMillis", + "failureCount", + "failedOperation", + "processName", + "protocolVersion", + "retryCount", + "samplingPeriod", + "sessionId", + "sourceCount", + "sourceType", + "state", + "stopReason", + "threadName", + ) + } +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt index 5ab2def..a711f73 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesRepository.kt @@ -1,9 +1,10 @@ package com.motionapps.sensorbox.core.preferences +import com.motionapps.sensorbox.core.error.AppResult import kotlinx.coroutines.flow.Flow interface AppPreferencesRepository { - val preferences: Flow> + val preferences: Flow> - suspend fun dispatch(intent: AppPreferencesIntent): Result + suspend fun dispatch(intent: AppPreferencesIntent): AppResult } diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt index aecb395..0814460 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt @@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.suspendAppResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow @@ -13,15 +15,15 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map class DataStoreAppPreferencesRepository(private val dataStore: DataStore) : AppPreferencesRepository { - override val preferences: Flow> = dataStore.data - .map { values -> Result.success(values.toAppPreferences()) } + override val preferences: Flow> = dataStore.data + .map { values -> AppResult.success(values.toAppPreferences()) } .catch { error -> if (error is CancellationException) throw error - emit(Result.failure(AppError.from(AppError.Kind.PREFERENCES, "Read preferences", error))) + emit(AppResult.failure(AppError.from(AppErrorCode.PREFERENCES, "Read preferences", error))) } - override suspend fun dispatch(intent: AppPreferencesIntent): Result = suspendAppResult( - AppError.Kind.PREFERENCES, + override suspend fun dispatch(intent: AppPreferencesIntent): AppResult = suspendAppResult( + AppErrorCode.PREFERENCES, "Update preferences", ) { dataStore.edit { values -> diff --git a/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt index 9557d42..eaa69e1 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/storage/NativeDocumentStorage.kt @@ -4,18 +4,32 @@ import android.content.Context import android.content.Intent import androidx.documentfile.provider.DocumentFile import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import java.io.InputStream import java.io.OutputStream object NativeDocumentStorage { - fun persistRootAccess(context: Context, intent: Intent, appDirectoryName: String): Result { + fun persistRootAccess(context: Context, intent: Intent, appDirectoryName: String): AppResult { val uri = intent.data ?: return storageFailure("Storage directory was not selected") val grantFlags = intent.flags and READ_WRITE_FLAGS if (grantFlags == 0) return storageFailure("Storage permission was not granted") - return appResult(AppError.Kind.STORAGE, "Persist storage permission") { - context.contentResolver.takePersistableUriPermission(uri, grantFlags) + return appResult(AppErrorCode.STORAGE, "Persist storage permission") { + when (grantFlags) { + Intent.FLAG_GRANT_READ_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + + Intent.FLAG_GRANT_WRITE_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + + else -> context.contentResolver.takePersistableUriPermission(uri, READ_WRITE_FLAGS) + } DocumentFile.fromTreeUri(context, uri) }.flatMap { selectedDirectory -> if (selectedDirectory?.isDirectory != true) { @@ -25,38 +39,41 @@ object NativeDocumentStorage { if (appDirectory(context, appDirectoryName, create = true) == null) { storageFailure("Storage directory is unavailable") } else { - Result.success(Unit) + AppResult.success(Unit) } } } - fun hasAppDirectory(context: Context, appDirectoryName: String): Result = appResult( - AppError.Kind.STORAGE, + fun hasAppDirectory(context: Context, appDirectoryName: String): AppResult = appResult( + AppErrorCode.STORAGE, "Check storage directory", ) { appDirectory(context, appDirectoryName, create = false)?.exists() == true } - fun displayPath(context: Context, appDirectoryName: String): Result = appResult( - AppError.Kind.STORAGE, + fun displayPath(context: Context, appDirectoryName: String): AppResult = appResult( + AppErrorCode.STORAGE, "Read storage path", ) { val selectedDirectory = appDirectory(context, appDirectoryName, create = false) ?: return@appResult null selectedDirectory.name ?: selectedDirectory.uri.lastPathSegment } - fun createMeasurementDirectory(context: Context, appDirectoryName: String, measurementName: String): Result = - appResult(AppError.Kind.STORAGE, "Access measurement root") { - appDirectory(context, appDirectoryName, create = false) - }.flatMap { appDirectory -> - if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") - appResult(AppError.Kind.STORAGE, "Create measurement directory") { - findDirectory(appDirectory, measurementName) != null || - appDirectory.createDirectory(measurementName) != null - }.flatMap { created -> - if (created) Result.success(Unit) else storageFailure("Unable to create measurement directory") - } + fun createMeasurementDirectory( + context: Context, + appDirectoryName: String, + measurementName: String, + ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement root") { + appDirectory(context, appDirectoryName, create = false) + }.flatMap { appDirectory -> + if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") + appResult(AppErrorCode.STORAGE, "Create measurement directory") { + findDirectory(appDirectory, measurementName) != null || + appDirectory.createDirectory(measurementName) != null + }.flatMap { created -> + if (created) AppResult.success(Unit) else storageFailure("Unable to create measurement directory") } + } fun openMeasurementFile( context: Context, @@ -65,29 +82,29 @@ object NativeDocumentStorage { mimeType: String, fileName: String, replaceExisting: Boolean = false, - ): Result = appResult(AppError.Kind.STORAGE, "Access measurement directory") { + ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement directory") { measurementDirectory(context, appDirectoryName, measurementName) }.flatMap { directory -> if (directory == null) return@flatMap storageFailure("Measurement directory is unavailable") createOrReplaceFile(directory, mimeType, fileName, replaceExisting).flatMap { createdFile -> if (createdFile == null) return@flatMap storageFailure("Unable to create measurement file") - appResult(AppError.Kind.STORAGE, "Open measurement file") { + appResult(AppErrorCode.STORAGE, "Open measurement file") { context.contentResolver.openOutputStream(createdFile.uri, "wt") }.flatMap { output -> - output?.let(Result.Companion::success) ?: storageFailure("Unable to open measurement file") + output?.let(AppResult.Companion::success) ?: storageFailure("Unable to open measurement file") } } } - fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): Result = - appResult(AppError.Kind.STORAGE, "Access measurement directory") { + fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): AppResult = + appResult(AppErrorCode.STORAGE, "Access measurement directory") { appDirectory(context, appDirectoryName, create = false) }.flatMap { appDirectory -> if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") val directory = findDirectory(appDirectory, measurementName) ?: return@flatMap storageFailure("Measurement does not exist") - appResult(AppError.Kind.STORAGE, "Delete measurement") { directory.delete() }.flatMap { deleted -> - if (deleted) Result.success(Unit) else storageFailure("Unable to delete measurement") + appResult(AppErrorCode.STORAGE, "Delete measurement") { directory.delete() }.flatMap { deleted -> + if (deleted) AppResult.success(Unit) else storageFailure("Unable to delete measurement") } } @@ -98,7 +115,7 @@ object NativeDocumentStorage { measurementName: String, fileName: String, mimeType: String, - ): Result = openMeasurementFile( + ): AppResult = openMeasurementFile( context = context, appDirectoryName = appDirectoryName, measurementName = measurementName, @@ -106,7 +123,7 @@ object NativeDocumentStorage { fileName = fileName, replaceExisting = true, ).flatMap { output -> - appResult(AppError.Kind.STORAGE, "Copy measurement file") { + appResult(AppErrorCode.STORAGE, "Copy measurement file") { input.use { source -> output.use(source::copyTo) } Unit } @@ -148,7 +165,7 @@ private fun createOrReplaceFile( mimeType: String, fileName: String, replaceExisting: Boolean, -): Result = appResult(AppError.Kind.STORAGE, "Create measurement file") { +): 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 @@ -157,8 +174,8 @@ private fun createOrReplaceFile( } } -private fun storageFailure(operation: String): Result = - Result.failure(AppError(AppError.Kind.STORAGE, operation)) +private fun storageFailure(operation: String): AppResult = + AppResult.failure(AppError(AppErrorCode.STORAGE, operation)) private fun releaseOtherRootPermissions(context: Context, selectedUri: android.net.Uri) { val resolver = context.contentResolver @@ -169,7 +186,7 @@ private fun releaseOtherRootPermissions(context: Context, selectedUri: android.n (if (permission.isReadPermission) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0) or (if (permission.isWritePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION else 0) if (flags != 0) { - appResult(AppError.Kind.STORAGE, "Release old storage permission") { + appResult(AppErrorCode.STORAGE, "Release old storage permission") { resolver.releasePersistableUriPermission(permission.uri, flags) } } diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt index 6623341..45c3c0e 100644 --- a/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/AppErrorTest.kt @@ -12,11 +12,11 @@ class AppErrorTest { fun `Given an operation failure When captured Then AppError is returned`() { val cause = IllegalStateException("disk unavailable") - val result = appResult(AppError.Kind.STORAGE, "Write file") { throw cause } + val result = appResult(AppErrorCode.STORAGE, "Write file") { throw cause } - val error = result.exceptionOrNull() + val error = result.errorOrNull() assertTrue(error is AppError) - assertEquals(AppError.Kind.STORAGE, (error as AppError).kind) + assertEquals(AppErrorCode.STORAGE, (error as AppError).code) assertEquals("Write file", error.operation) assertSame(cause, error.cause) } @@ -24,7 +24,7 @@ class AppErrorTest { @Test(expected = CancellationException::class) fun `Given coroutine cancellation When captured Then cancellation is rethrown`() { runBlocking { - suspendAppResult(AppError.Kind.CONNECTIVITY, "Send message") { + suspendAppResult(AppErrorCode.CONNECTIVITY, "Send message") { throw CancellationException("cancelled") } } diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt new file mode 100644 index 0000000..b14462f --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt @@ -0,0 +1,95 @@ +package com.motionapps.sensorbox.core.error + +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 + +class FileDiagnosticsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `Given many events When logs rotate Then only two one megabyte files remain`() { + val directory = temporaryFolder.newFolder("diagnostics") + val diagnostics = diagnostics(directory) + + repeat(2_500) { index -> diagnostics.record(event(message = "$index ${"x".repeat(1_000)}")) } + + val retainedLogs = directory.listFiles().orEmpty().filter { "export" !in it.name } + assertEquals(2, retainedLogs.size) + assertTrue(retainedLogs.all { it.length() <= 1_000_000L }) + assertTrue(diagnostics.readText().getOrNull().orEmpty().contains("2499 ")) + } + + @Test + fun `Given private context When event is recorded Then only allowlisted context is written`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + diagnostics.record( + event( + message = "Controlled failure message", + context = mapOf( + "sessionId" to "session-123", + "sensorSamples" to "1.2,3.4,5.6", + "coordinates" to "48.1,17.1", + "annotation" to "private note", + "recordingName" to "private recording", + "safUri" to "content://private/path", + "rawPayload" to "secret bytes", + "deviceId" to "unique-device-id", + ), + ), + ) + + val text = diagnostics.readText().getOrNull().orEmpty() + assertTrue(text.contains("sessionId=session-123")) + assertFalse(text.contains("1.2,3.4,5.6")) + assertFalse(text.contains("48.1,17.1")) + assertFalse(text.contains("private note")) + assertFalse(text.contains("private recording")) + assertFalse(text.contains("content://private/path")) + assertFalse(text.contains("secret bytes")) + assertFalse(text.contains("unique-device-id")) + } + + @Test + fun `Given an unwritable location When event is recorded Then logger does not throw`() { + val fileInsteadOfDirectory = temporaryFolder.newFile("not-a-directory") + val diagnostics = diagnostics(fileInsteadOfDirectory) + + diagnostics.record(event(message = "Write failure")) + + assertTrue(fileInsteadOfDirectory.isFile) + } + + @Test + fun `Given retained logs When cleared Then reading returns the empty message`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + diagnostics.record(event(message = "Before clear")) + + assertTrue(diagnostics.clear().isSuccess) + + assertEquals("No diagnostics have been recorded.\n", diagnostics.readText().getOrNull()) + } + + private fun diagnostics(directory: java.io.File) = FileDiagnostics( + diagnosticsDirectory = directory, + metadata = DiagnosticMetadata( + appVersion = "1.0", + buildType = "test", + deviceModel = "test model", + androidVersion = "test android", + processName = "test process", + ), + ) + + private fun event(message: String, context: Map = emptyMap()) = DiagnosticEvent( + severity = DiagnosticSeverity.ERROR, + code = AppErrorCode.STORAGE, + operation = "Test operation", + diagnosticMessage = message, + context = context, + ) +} diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt index c6ce1eb..ede7036 100644 --- a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeAppPreferencesRepository.kt @@ -1,5 +1,7 @@ package com.motionapps.sensorbox.core.testing +import com.motionapps.sensorbox.core.error.AppResult + import com.motionapps.sensorbox.core.preferences.AppPreferences import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent import com.motionapps.sensorbox.core.preferences.AppPreferencesReducer @@ -12,10 +14,10 @@ class FakeAppPreferencesRepository( ) : AppPreferencesRepository { private val mutablePreferences = MutableStateFlow(initial) - override val preferences = mutablePreferences.map(Result.Companion::success) + override val preferences = mutablePreferences.map(AppResult.Companion::success) - override suspend fun dispatch(intent: AppPreferencesIntent): Result { + override suspend fun dispatch(intent: AppPreferencesIntent): AppResult { mutablePreferences.value = AppPreferencesReducer.reduce(mutablePreferences.value, intent) - return Result.success(Unit) + return AppResult.success(Unit) } } diff --git a/docs/adr/0001-use-explicit-application-outcomes.md b/docs/adr/0001-use-explicit-application-outcomes.md new file mode 100644 index 0000000..5244340 --- /dev/null +++ b/docs/adr/0001-use-explicit-application-outcomes.md @@ -0,0 +1,5 @@ +# Use explicit application outcomes + +Expected failures crossing repository, storage, protocol, recording, use-case, or presentation interfaces use a shared `AppResult` and data-only `AppError`. Kotlin `Result` and custom exceptions do not cross those interfaces because callers need stable error codes, exhaustive handling, and diagnostics without constructor side effects. + +Infrastructure adapters translate expected exceptions and preserve safe causes. Application entry points record terminal failures through an injected local diagnostic logger. User-facing messages remain presentation decisions. diff --git a/docs/adr/0002-separate-recording-engine-from-android-adapters.md b/docs/adr/0002-separate-recording-engine-from-android-adapters.md new file mode 100644 index 0000000..e967f9e --- /dev/null +++ b/docs/adr/0002-separate-recording-engine-from-android-adapters.md @@ -0,0 +1,5 @@ +# Separate the recording engine from Android adapters + +Recording state, scheduling policy, source coordination, cleanup, and terminal events live in a pure Kotlin `recording-core` module. Android sensor capture, foreground hosting, notifications, wake locks, battery callbacks, GPS, and storage streams remain adapters in `sensorservices`. + +This split keeps the recording state machine testable without Android and prevents Wear transport policy from entering the local recording module. diff --git a/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md b/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md new file mode 100644 index 0000000..616fb88 --- /dev/null +++ b/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md @@ -0,0 +1,5 @@ +# Coordinate paired recordings with protocol version 2 + +Paired phone and watch recording uses a breaking, session-identified prepare, commit, abort, and stop protocol. Both sides must prepare before either side commits. Commands are idempotent, bounded by timeouts, and retried with the same session ID. + +The phone owns the paired workflow. `WearOsLib` owns typed protocol encoding and transport, while phone and watch applications own command behavior. Unsupported protocol versions fail explicitly. diff --git a/docs/adr/0004-own-presentation-state-by-user-workflow.md b/docs/adr/0004-own-presentation-state-by-user-workflow.md new file mode 100644 index 0000000..1466b17 --- /dev/null +++ b/docs/adr/0004-own-presentation-state-by-user-workflow.md @@ -0,0 +1,5 @@ +# Own presentation state by user workflow + +Phone presentation state is owned by onboarding, recording, and settings workflows rather than one application-wide MVI contract. The recording workflow spans selection, details, preview, setup, permissions, active recording, and annotations because those routes edit or observe one recording plan. + +The application shell owns navigation and a small read-only view of recording session facts. Routes remain stateless and receive focused state and actions from their owning feature. diff --git a/docs/architecture-refactor-plan.md b/docs/architecture-refactor-plan.md new file mode 100644 index 0000000..2d1fbff --- /dev/null +++ b/docs/architecture-refactor-plan.md @@ -0,0 +1,117 @@ +# SensorBox architecture refactor plan + +Status: implemented and verified on 2026-08-19. The phone emulator workflow passes. The Wear-to-phone emulator workflow is built but awaits a one-time Android Studio phone/watch pairing. + +This file is the implementation source of truth for the Android 17 hard-cut refactor. Update the status boxes as work lands. Do not preserve an old interface merely because a later step still uses it. Each completed step must leave the repository in a valid intermediate state. + +Related decisions: + +- [0001: Use explicit application outcomes](adr/0001-use-explicit-application-outcomes.md) +- [0002: Separate the recording engine from Android adapters](adr/0002-separate-recording-engine-from-android-adapters.md) +- [0003: Coordinate paired recordings with protocol version 2](adr/0003-coordinate-paired-recordings-with-protocol-v2.md) +- [0004: Own presentation state by user workflow](adr/0004-own-presentation-state-by-user-workflow.md) + +## Completion rules + +- [x] Every module interface uses `AppResult` for expected outcomes. Kotlin `Result` does not cross a module interface. +- [x] Every intermediate implementation state compiles and passes the checks relevant to its changes. +- [ ] The final state passes unit tests, Detekt, phone and watch lint, Android test compilation, and both emulator workflows. +- [x] Tests cross the same interfaces used by production callers. +- [x] Diagnostics remain local until the user copies or shares them. +- [x] `sensorservices` has no dependency on `wearoslib`. +- [x] Unsupported Wear protocol versions fail explicitly. No compatibility layer remains. + +## Phase 1: outcomes and diagnostics + +- [x] Replace exception-backed `AppError` with a data-only error. +- [x] Add stable error codes for connectivity, external actions, measurement, permission, preferences, storage, validation, timeout, conflict, and unknown failures. +- [x] Add `AppResult.Success` and `AppResult.Failure` plus composition helpers. +- [x] Translate expected exceptions at infrastructure adapters and rethrow coroutine cancellation. +- [x] Add `DiagnosticLogger` for writes and `DiagnosticsStore` for read, export, and clear operations. +- [x] Store structured, redacted diagnostics in app-private rotating files. +- [x] Keep the current and previous diagnostic file at no more than 1 MB each. +- [x] Expose view, copy, share-text, share-file, and clear actions from Settings. +- [x] Add tests for outcome composition, cancellation, redaction, rotation, and logger write failure. + +Diagnostic entries may contain timestamps, severity, stable codes, operations, app/build information, Android/device model information, process names, session IDs, lifecycle states, stop reasons, source types, protocol versions, retry counts, sanitized exception classes, stack traces, durations, source counts, and sampling periods. + +Diagnostic entries must not contain samples, coordinates, annotations, command payloads, full recording names, complete file names, SAF URIs, or persistent device identifiers. + +## Phase 2: pure recording engine + +- [x] Add a pure Kotlin `recording-core` module. +- [x] Define typed recording plans, session IDs, source types, stop reasons, states, commands, and terminal events. +- [x] Define the small source role used internally by the engine. +- [x] Prepare and start sources in stable order. +- [x] On start failure, stop all prepared sources in reverse order. +- [x] On stop, attempt every active source and combine failures. +- [x] Make repeated stop idempotent. +- [x] Emit typed stop events for duration expiry, low battery, source failure, platform destruction, and user requests. +- [x] Test partial start, reverse cleanup, repeated stop, timed stop, low-battery stop, and combined cleanup failure. + +The durable state sequence is `Idle`, `Preparing`, `Prepared`, `Running`, and `Stopping`. Terminal results are `RecordingStarted`, `RecordingStartRejected`, and `RecordingStopped` events. The engine returns to `Idle` after a terminal event. + +## Phase 3: Android recording adapters + +- [x] Keep Android sensors, GPS, activity recognition, significant motion, storage streams, alarms, wake locks, battery callbacks, notifications, and foreground hosting in `sensorservices`. +- [x] Replace `Bundle` source configuration with typed configuration. +- [x] Make `MeasurementService` translate intents into engine commands. +- [x] Publish engine state through an application-scoped read-only session repository. +- [x] Deliver typed session events to application-provided observers. +- [x] Remove Wear commands, codecs, connection collaborators, and paired flags from `sensorservices`. +- [x] Remove the `sensorservices -> wearoslib` Gradle dependency. + +## Phase 4: Wear protocol version 2 + +- [x] Add session identity to every paired recording command. +- [x] Add prepare, commit, abort, stop, and correlated acknowledgement messages with typed outcomes. +- [x] Make handlers idempotent and return the prior outcome for duplicate commands. +- [x] Reject a command for a conflicting active session with `CONFLICT`. +- [x] Reject unsupported protocol versions. +- [x] Keep command models, encoding, decoding, connection discovery, and byte transport in `WearOsLib`. +- [x] Add round-trip, unsupported-version, duplicate-command, and malformed-payload tests. + +## Phase 5: paired recording workflow + +- [x] Put phone and watch coordination in one phone-side workflow. +- [x] Prepare both sides before committing either side. +- [x] Give prepare 10 seconds and commit acknowledgement 5 seconds. +- [x] Retry each message twice with the same session ID. +- [x] Abort both sides after rejection or final timeout. +- [x] Never fall back to phone-only recording when the plan contains watch sources. +- [x] Propagate every automatic local stop to the watch through the typed session observer. +- [x] Stop both sides even if one stop fails, then return a combined outcome. +- [x] Test remote rejection, prepare timeout, commit compensation, duplicate messages, and partial stop failure. + +## Phase 6: presentation ownership + +- [x] Make the application shell own navigation and only the session facts needed for navigation and screen-awake policy. +- [x] Give onboarding its own state, actions, effects, and ViewModel. +- [x] Give the full recording journey one navigation-graph-scoped ViewModel. +- [x] Give settings its own state, actions, effects, and ViewModel. +- [x] Keep child routes stateless and pass focused state and actions. +- [x] Move recording request construction, validation, permission resolution, and start effects into the recording feature. +- [x] Test ViewModel actions, state transitions, effects, and representative error codes. + +The recording journey includes sensor selection, sensor details, live preview, measurement setup, permission resolution, active recording, and annotations. + +## Phase 7: typed Wear handlers + +- [x] Keep listener services limited to callback validation, decoding, and dispatch. +- [x] Put phone command behavior in `PhoneWearCommandHandler` in `app`. +- [x] Put watch command behavior in `WearCommandHandler` in `wear`. +- [x] Keep application preferences, permissions, recording behavior, and navigation out of `WearOsLib`. +- [x] Test handlers with typed commands and local adapters, without Google Play Services callback objects. + +## Final verification + +- [x] `./gradlew testDebugUnitTest detekt` +- [x] `./gradlew :app:assembleDebug :wear:assembleDebug` +- [x] `./gradlew :app:lintDebug :wear:lintDebug` +- [x] `./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin` +- [x] Run the phone sensor recording emulator workflow. +- [ ] Run the Wear-to-phone transfer emulator workflow. +- [x] Update README architecture and test instructions. +- [x] Confirm the final worktree contains no obsolete result, protocol, presentation, or service interfaces. + +Wear workflow note: `tools/emulator/run_wear_sync_test.sh` reached its pairing precondition and exited with the expected guidance because `SensorBox_Wear_API_37` reports no paired phone. APKs and both instrumentation suites compile. Complete the one-time Pair Wearable flow in Android Studio, then rerun the script. diff --git a/docs/images/sensorbox-intro-battery.png b/docs/images/sensorbox-intro-battery.png new file mode 100644 index 0000000..f47d59e Binary files /dev/null and b/docs/images/sensorbox-intro-battery.png differ diff --git a/docs/images/sensorbox-intro-lifecycle.png b/docs/images/sensorbox-intro-lifecycle.png new file mode 100644 index 0000000..0e13ac5 Binary files /dev/null and b/docs/images/sensorbox-intro-lifecycle.png differ diff --git a/docs/images/sensorbox-intro-policy.png b/docs/images/sensorbox-intro-policy.png new file mode 100644 index 0000000..dd665e3 Binary files /dev/null and b/docs/images/sensorbox-intro-policy.png differ diff --git a/docs/images/sensorbox-intro-privacy.png b/docs/images/sensorbox-intro-privacy.png new file mode 100644 index 0000000..1d2c15a Binary files /dev/null and b/docs/images/sensorbox-intro-privacy.png differ diff --git a/docs/images/sensorbox-intro-storage.png b/docs/images/sensorbox-intro-storage.png new file mode 100644 index 0000000..654b290 Binary files /dev/null and b/docs/images/sensorbox-intro-storage.png differ diff --git a/docs/images/sensorbox-intro-welcome.png b/docs/images/sensorbox-intro-welcome.png new file mode 100644 index 0000000..9c2b0f7 Binary files /dev/null and b/docs/images/sensorbox-intro-welcome.png differ diff --git a/docs/images/sensorbox-phone-record.png b/docs/images/sensorbox-phone-record.png new file mode 100644 index 0000000..2bb22be Binary files /dev/null and b/docs/images/sensorbox-phone-record.png differ diff --git a/docs/images/sensorbox-phone-setup.png b/docs/images/sensorbox-phone-setup.png new file mode 100644 index 0000000..fe4c522 Binary files /dev/null and b/docs/images/sensorbox-phone-setup.png differ diff --git a/docs/images/sensorbox-wear-live.png b/docs/images/sensorbox-wear-live.png new file mode 100644 index 0000000..a452929 Binary files /dev/null and b/docs/images/sensorbox-wear-live.png differ diff --git a/docs/images/sensorbox-wear.png b/docs/images/sensorbox-wear.png new file mode 100644 index 0000000..0416ad9 Binary files /dev/null and b/docs/images/sensorbox-wear.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb823c2..ef8e8ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,4 +70,5 @@ detekt = { id = "dev.detekt", version.ref = "detekt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } diff --git a/recording-core/build.gradle.kts b/recording-core/build.gradle.kts new file mode 100644 index 0000000..0c54959 --- /dev/null +++ b/recording-core/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.detekt) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":core-common")) + implementation(libs.coroutines.core) + + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt new file mode 100644 index 0000000..2a8fb45 --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt @@ -0,0 +1,226 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.combineAppResults +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class RecordingEngine( + sources: List, + private val scope: CoroutineScope, + private val clock: RecordingClock, + private val delay: RecordingDelay, +) { + private val sourceByType = sources.associateBy(RecordingSource::type) + private val mutex = Mutex() + private val mutableState = MutableStateFlow(RecordingSessionState.Idle) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) + private var activeSources: List = emptyList() + private var committingSessionId: RecordingSessionId? = null + private var durationJob: Job? = null + private var lastCompletedSessionId: RecordingSessionId? = null + private var lastStopResult: AppResult = AppResult.success(Unit) + + val state: StateFlow = mutableState.asStateFlow() + val events: SharedFlow = mutableEvents.asSharedFlow() + + suspend fun prepare(plan: RecordingPlan): AppResult = mutex.withLock { + if (mutableState.value !is RecordingSessionState.Idle) { + return@withLock conflict("Prepare recording", plan.sessionId) + } + val validation = validate(plan) + if (validation is AppResult.Failure) return@withLock reject(plan.sessionId, validation.error) + + mutableState.value = RecordingSessionState.Preparing(plan) + val prepared = mutableListOf() + for (spec in plan.sources.sortedBy { it.type.ordinal }) { + val source = checkNotNull(sourceByType[spec.type]) + when (val result = source.prepare(spec)) { + is AppResult.Success -> prepared += source + + is AppResult.Failure -> { + val sourcesToClean = prepared + source + activeSources = sourcesToClean + return@withLock rejectWithCleanup(plan.sessionId, result.error, sourcesToClean) + } + } + } + activeSources = prepared + mutableState.value = RecordingSessionState.Prepared(plan) + AppResult.success(Unit) + } + + suspend fun commit(sessionId: RecordingSessionId): AppResult = when (val claim = claimCommit(sessionId)) { + is AppResult.Failure -> claim + + is AppResult.Success -> claim.value?.let { plan -> + delay.pause((plan.startAtEpochMillis - clock.epochMillis()).coerceAtLeast(0L)) + startPreparedSources(sessionId, plan) + } ?: AppResult.success(Unit) + } + + private suspend fun claimCommit(sessionId: RecordingSessionId): AppResult = mutex.withLock { + when (val current = mutableState.value) { + is RecordingSessionState.Running -> if (current.plan.sessionId == sessionId) { + AppResult.success(null) + } else { + conflict("Commit recording", sessionId) + } + + is RecordingSessionState.Prepared -> claimPreparedCommit(current, sessionId) + + else -> conflict("Commit recording", sessionId) + } + } + + private fun claimPreparedCommit( + current: RecordingSessionState.Prepared, + sessionId: RecordingSessionId, + ): AppResult = when { + current.plan.sessionId != sessionId -> conflict("Commit recording", sessionId) + + committingSessionId == sessionId -> AppResult.success(null) + + else -> { + committingSessionId = sessionId + AppResult.success(current.plan) + } + } + + private suspend fun startPreparedSources(sessionId: RecordingSessionId, plan: RecordingPlan): AppResult = + mutex.withLock { + val prepared = mutableState.value as? RecordingSessionState.Prepared + if (prepared?.plan?.sessionId != sessionId) { + return@withLock conflict("Commit recording", sessionId) + } + + for (source in activeSources) { + when (val result = source.start()) { + is AppResult.Success -> Unit + + is AppResult.Failure -> { + committingSessionId = null + return@withLock rejectWithCleanup(sessionId, result.error, activeSources) + } + } + } + committingSessionId = null + val startedAt = clock.epochMillis() + mutableState.value = RecordingSessionState.Running(plan, startedAt) + mutableEvents.tryEmit(RecordingEvent.RecordingStarted(sessionId, startedAt)) + scheduleDurationStop(plan) + AppResult.success(Unit) + } + + suspend fun abort(sessionId: RecordingSessionId): AppResult = + stop(sessionId, RecordingStopReason.PAIRED_ABORT) + + suspend fun stop(sessionId: RecordingSessionId, reason: RecordingStopReason): AppResult = mutex.withLock { + val current = mutableState.value + if (current is RecordingSessionState.Idle) { + return@withLock if (lastCompletedSessionId == sessionId) { + lastStopResult + } else { + conflict("Stop recording", sessionId) + } + } + if (current.sessionId() != sessionId) return@withLock conflict("Stop recording", sessionId) + + mutableState.value = RecordingSessionState.Stopping(sessionId, reason) + val currentJob = currentCoroutineContext()[Job] + durationJob?.takeIf { it !== currentJob }?.cancel() + durationJob = null + committingSessionId = null + val results = activeSources.asReversed().map { it.stop() } + activeSources = emptyList() + val result = results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") + lastCompletedSessionId = sessionId + lastStopResult = result + mutableState.value = RecordingSessionState.Idle + mutableEvents.tryEmit(RecordingEvent.RecordingStopped(sessionId, reason, result)) + result + } + + private suspend fun rejectWithCleanup( + sessionId: RecordingSessionId, + startError: AppError, + sources: List, + ): AppResult { + val cleanup = sources.asReversed().map { it.stop() } + activeSources = emptyList() + val result = (listOf(AppResult.failure(startError)) + cleanup).combineAppResults( + AppErrorCode.MEASUREMENT, + "Reject recording start", + ) + return reject(sessionId, checkNotNull(result.errorOrNull())) + } + + private fun reject(sessionId: RecordingSessionId, error: AppError): AppResult { + committingSessionId = null + mutableState.value = RecordingSessionState.Idle + mutableEvents.tryEmit(RecordingEvent.RecordingStartRejected(sessionId, error)) + return AppResult.failure(error) + } + + private fun validate(plan: RecordingPlan): AppResult { + val message = when { + plan.sources.isEmpty() -> "Recording plan has no sources" + + plan.durationMillis < 0L -> "Recording duration is negative" + + plan.sources.map(RecordingSourceSpec::type).distinct().size != plan.sources.size -> + "Recording plan repeats a source type" + + plan.sources.map(RecordingSourceSpec::type).any { it !in sourceByType } -> + "Recording source adapter is missing" + + else -> null + } + return message?.let(::validationFailure) ?: AppResult.success(Unit) + } + + private fun validationFailure(message: String): AppResult = AppResult.failure( + AppError(AppErrorCode.VALIDATION, "Validate recording plan", message), + ) + + private fun conflict(operation: String, sessionId: RecordingSessionId): AppResult = AppResult.failure( + AppError( + code = AppErrorCode.CONFLICT, + operation = operation, + diagnosticMessage = "$operation conflicts with the active session", + context = mapOf("sessionId" to sessionId.value), + ), + ) + + private fun scheduleDurationStop(plan: RecordingPlan) { + if (plan.durationMillis <= 0L) return + durationJob = scope.launch { + delay.pause(plan.durationMillis) + stop(plan.sessionId, RecordingStopReason.DURATION_EXPIRED) + } + } + + private fun RecordingSessionState.sessionId(): RecordingSessionId? = when (this) { + RecordingSessionState.Idle -> null + is RecordingSessionState.Preparing -> plan.sessionId + is RecordingSessionState.Prepared -> plan.sessionId + is RecordingSessionState.Running -> plan.sessionId + is RecordingSessionState.Stopping -> sessionId + } + + private companion object { + const val EVENT_BUFFER_SIZE = 16 + } +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt new file mode 100644 index 0000000..bf5e456 --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt @@ -0,0 +1,92 @@ +package com.motionapps.sensorbox.recording + +import java.util.UUID + +@JvmInline +value class RecordingSessionId(val value: String) { + init { + require(value.isNotBlank()) + } + + companion object { + fun create(): RecordingSessionId = RecordingSessionId(UUID.randomUUID().toString()) + } +} + +enum class RecordingSourceType { + SESSION, + SENSOR, + GPS, + ACTIVITY_RECOGNITION, + SIGNIFICANT_MOTION, +} + +sealed interface RecordingSourceSpec { + val type: RecordingSourceType + + data object Session : RecordingSourceSpec { + override val type = RecordingSourceType.SESSION + } + + data class Sensors(val sensorTypes: Set, val samplingPeriod: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.SENSOR + } + + data class Gps(val intervalSeconds: Int, val minimumDistanceMeters: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.GPS + } + + data class ActivityRecognition(val periodSeconds: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.ACTIVITY_RECOGNITION + } + + data object SignificantMotion : RecordingSourceSpec { + override val type = RecordingSourceType.SIGNIFICANT_MOTION + } +} + +data class RecordingPlan( + val sessionId: RecordingSessionId, + val sources: List, + val startAtEpochMillis: Long, + val durationMillis: Long = 0L, +) + +sealed interface RecordingSessionState { + data object Idle : RecordingSessionState + + data class Preparing(val plan: RecordingPlan) : RecordingSessionState + + data class Prepared(val plan: RecordingPlan) : RecordingSessionState + + data class Running(val plan: RecordingPlan, val startedAtEpochMillis: Long) : RecordingSessionState + + data class Stopping(val sessionId: RecordingSessionId, val reason: RecordingStopReason) : RecordingSessionState +} + +enum class RecordingStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + PLATFORM_DESTROYED, + PAIRED_ABORT, +} + +sealed interface RecordingEvent { + val sessionId: RecordingSessionId + + data class RecordingStarted(override val sessionId: RecordingSessionId, val startedAtEpochMillis: Long) : + RecordingEvent + + data class RecordingStartRejected( + override val sessionId: RecordingSessionId, + val error: com.motionapps.sensorbox.core.error.AppError, + ) : RecordingEvent + + data class RecordingStopped( + override val sessionId: RecordingSessionId, + val reason: RecordingStopReason, + val result: com.motionapps.sensorbox.core.error.AppResult, + ) : RecordingEvent +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt new file mode 100644 index 0000000..908737f --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppResult + +interface RecordingSource { + val type: RecordingSourceType + + suspend fun prepare(spec: RecordingSourceSpec): AppResult + + suspend fun start(): AppResult + + suspend fun stop(): AppResult +} + +fun interface RecordingClock { + fun epochMillis(): Long +} + +fun interface RecordingDelay { + suspend fun pause(durationMillis: Long) +} diff --git a/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt b/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt new file mode 100644 index 0000000..d64d37d --- /dev/null +++ b/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt @@ -0,0 +1,188 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +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 RecordingEngineTest { + @Test + fun `Given unordered sources When committed Then sources start in stable order`() = runTest { + val calls = mutableListOf() + val gps = FakeSource(RecordingSourceType.GPS, calls) + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Gps(10, 20), + RecordingSourceSpec.Sensors(setOf(1), 0), + ) + + assertTrue(engine.prepare(plan).isSuccess) + assertTrue(engine.commit(plan.sessionId).isSuccess) + + assertEquals( + listOf("prepare:SENSOR", "prepare:GPS", "start:SENSOR", "start:GPS"), + calls, + ) + assertTrue(engine.state.value is RecordingSessionState.Running) + } + + @Test + fun `Given a partial start failure When committed Then started sources stop in reverse order`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls, startFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + engine.prepare(plan) + val result = engine.commit(plan.sessionId) + + assertTrue(result.isFailure) + assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) + assertEquals(RecordingSessionState.Idle, engine.state.value) + } + + @Test + fun `Given a prepare failure When prepared Then failing and prepared sources clean up in reverse order`() = + runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls, prepareFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + val result = engine.prepare(plan) + + assertTrue(result.isFailure) + assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) + assertEquals(RecordingSessionState.Idle, engine.state.value) + } + + @Test + fun `Given stop failures When stopped Then every source is attempted`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls, stopFails = true) + val gps = FakeSource(RecordingSourceType.GPS, calls, stopFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val result = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + + assertTrue(result.isFailure) + assertTrue(calls.contains("stop:GPS")) + assertTrue(calls.contains("stop:SENSOR")) + } + + @Test + fun `Given a completed session When stop repeats Then sources are not stopped twice`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(sensors)) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val first = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + val second = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + + assertEquals(first, second) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + @Test + fun `Given a duration When committed Then recording stops after duration`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = RecordingEngine( + sources = listOf(sensors), + scope = backgroundScope, + clock = RecordingClock { testScheduler.currentTime }, + delay = RecordingDelay { kotlinx.coroutines.delay(it) }, + ) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) + + engine.prepare(plan) + engine.commit(plan.sessionId) + advanceTimeBy(500L) + runCurrent() + + assertEquals(RecordingSessionState.Idle, engine.state.value) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + @Test + fun `Given a running session When low battery stops it Then terminal state returns to idle`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(sensors)) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val result = engine.stop(plan.sessionId, RecordingStopReason.LOW_BATTERY) + + assertTrue(result.isSuccess) + assertEquals(RecordingSessionState.Idle, engine.state.value) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + private fun engine(sources: List) = RecordingEngine( + sources = sources, + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + clock = RecordingClock { 0L }, + delay = RecordingDelay { }, + ) + + private fun plan(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingPlan( + sessionId = RecordingSessionId.create(), + sources = sources.toList(), + startAtEpochMillis = 0L, + durationMillis = durationMillis, + ) +} + +private class FakeSource( + override val type: RecordingSourceType, + private val calls: MutableList, + private val prepareFails: Boolean = false, + private val startFails: Boolean = false, + private val stopFails: Boolean = false, +) : RecordingSource { + override suspend fun prepare(spec: RecordingSourceSpec): AppResult { + calls += "prepare:$type" + return if (prepareFails) failure("Prepare $type") else AppResult.success(Unit) + } + + override suspend fun start(): AppResult { + calls += "start:$type" + return if (startFails) failure("Start $type") else AppResult.success(Unit) + } + + override suspend fun stop(): AppResult { + calls += "stop:$type" + return if (stopFails) failure("Stop $type") else AppResult.success(Unit) + } + + private fun failure(operation: String): AppResult = AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, operation), + ) +} diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index e6114af..234aac3 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -37,7 +37,7 @@ android { dependencies { implementation(project(":core")) - implementation(project(":wearoslib")) + implementation(project(":recording-core")) implementation(libs.androidx.core.ktx) implementation(libs.play.services.location) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt index a42ac7f..930c1e2 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/GPSHandler.kt @@ -14,6 +14,8 @@ import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap @@ -62,12 +64,12 @@ class GPSHandler : LocationCallback() { callback?.onLastLocationSuccess(location) } }.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Read last GPS location", error) + AppError.from(AppErrorCode.MEASUREMENT, "Read last GPS location", error) callback?.onLastLocationSuccess(null) } locationClient.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request GPS updates", error) } registered = true } @@ -95,14 +97,14 @@ class GPSHandler : LocationCallback() { } /** Stops location updates for the active measurement. */ - fun gpsOff(): Result = appResult(AppError.Kind.MEASUREMENT, "Stop GPS updates") { + fun gpsOff(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Stop GPS updates") { if (registered) { Log.i(tag, "Logging off location") locationClient.flushLocations().addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Flush GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Flush GPS updates", error) } locationClient.removeLocationUpdates(this).addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove GPS updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Remove GPS updates", error) } } registered = false @@ -133,9 +135,9 @@ class GPSHandler : LocationCallback() { * @param gpsCallback - this object will get access to location and updates, previous is forgotten * */ - fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): Result = - (if (registered) gpsOff() else Result.success(Unit)).flatMap { - appResult(AppError.Kind.MEASUREMENT, "Register GPS callback") { + fun addCallback(context: Context, gpsCallback: OnLocationChangedCallback): AppResult = + (if (registered) gpsOff() else AppResult.success(Unit)).flatMap { + appResult(AppErrorCode.MEASUREMENT, "Register GPS callback") { callback = gpsCallback initialize(context) gpsCallback.onLocationChanged(lastLocation) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt index 7428763..b63fdbc 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt @@ -3,6 +3,8 @@ package com.motionapps.sensorservices.handlers import android.content.Context import android.content.Intent import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.storage.NativeDocumentStorage @@ -21,15 +23,15 @@ object StorageHandler { return SimpleDateFormat(format, Locale.getDefault()).format(calendar.time) } - fun createMainFolder(context: Context, intent: Intent?): Result { + fun createMainFolder(context: Context, intent: Intent?): AppResult { val directoryName = context.getString(R.string.app_name) return if (intent == null) { NativeDocumentStorage.hasAppDirectory(context, directoryName).flatMap { exists -> if (exists) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.STORAGE, "Storage directory is not configured"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Storage directory is not configured"), ) } } @@ -38,34 +40,34 @@ object StorageHandler { } } - fun isFolder(context: Context): Result = NativeDocumentStorage.hasAppDirectory( + fun isFolder(context: Context): AppResult = NativeDocumentStorage.hasAppDirectory( context = context, appDirectoryName = context.getString(R.string.app_name), ) - fun isAccess(context: Context): Result = isFolder(context) + fun isAccess(context: Context): AppResult = isFolder(context) - fun getFolderName(context: Context): Result = NativeDocumentStorage.displayPath( + fun getFolderName(context: Context): AppResult = NativeDocumentStorage.displayPath( context = context, appDirectoryName = context.getString(R.string.app_name), ).map { it ?: context.getString(R.string.no_path) } - fun createInternalStorageMeasurementFolder(context: Context, folderName: String): Result { + fun createInternalStorageMeasurementFolder(context: Context, folderName: String): AppResult { val directory = internalMeasurementDirectory(context, folderName) - return appResult(AppError.Kind.STORAGE, "Create internal measurement directory") { + return appResult(AppErrorCode.STORAGE, "Create internal measurement directory") { directory.exists() || directory.mkdirs() }.flatMap { created -> if (created) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.STORAGE, "Create internal measurement directory"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), ) } } } - fun createFolderMeasurement(context: Context, folderName: String): Result = + fun createFolderMeasurement(context: Context, folderName: String): AppResult = NativeDocumentStorage.createMeasurementDirectory( context = context, appDirectoryName = context.getString(R.string.app_name), @@ -77,7 +79,7 @@ object StorageHandler { folderName: String, mimeOfNewFile: String, nameOfNewFile: String, - ): Result = NativeDocumentStorage.openMeasurementFile( + ): AppResult = NativeDocumentStorage.openMeasurementFile( context = context, appDirectoryName = context.getString(R.string.app_name), measurementName = folderName, @@ -85,23 +87,23 @@ object StorageHandler { fileName = nameOfNewFile, ) - fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): Result = - appResult(AppError.Kind.STORAGE, "Prepare internal measurement directory") { + fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): AppResult = + appResult(AppErrorCode.STORAGE, "Prepare internal measurement directory") { val directory = internalMeasurementDirectory(context, folderName) directory to (directory.exists() || directory.mkdirs()) }.flatMap { (directory, ready) -> if (!ready) { - Result.failure( - AppError(AppError.Kind.STORAGE, "Create internal measurement directory"), + AppResult.failure( + AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), ) } else { - appResult(AppError.Kind.STORAGE, "Open internal measurement file") { + appResult(AppErrorCode.STORAGE, "Open internal measurement file") { FileOutputStream(File(directory, nameOfFile)) } } } - fun deleteByNameOfFolder(context: Context, deleteFolder: String): Result = + fun deleteByNameOfFolder(context: Context, deleteFolder: String): AppResult = NativeDocumentStorage.deleteMeasurement( context = context, appDirectoryName = context.getString(R.string.app_name), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt index 5995c6f..acee0df 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt @@ -1,6 +1,7 @@ package com.motionapps.sensorservices.handlers.measurements import android.Manifest +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context @@ -8,7 +9,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build -import android.os.Bundle import androidx.core.content.ContextCompat import com.google.android.gms.location.ActivityRecognition import com.google.android.gms.location.ActivityRecognitionClient @@ -18,6 +18,8 @@ import com.google.android.gms.location.ActivityTransitionRequest import com.google.android.gms.location.ActivityTransitionResult import com.google.android.gms.location.DetectedActivity import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -26,7 +28,7 @@ import com.motionapps.sensorservices.handlers.StorageHandler import java.io.OutputStream /** Records periodic Google Activity Recognition confidence values. */ -class ActivityRecognitionMeasurement(private val periodSeconds: Int) : MeasurementInterface { +class ActivityRecognitionMeasurement(private val periodSeconds: Int) { private var client: ActivityRecognitionClient? = null private var updatesPendingIntent: PendingIntent? = null private var transitionsPendingIntent: PendingIntent? = null @@ -64,32 +66,30 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme } } - override fun initMeasurement(context: Context, params: Bundle): Result { - val folder = params.getString(MeasurementInterface.FOLDER_NAME).orEmpty() - val internal = params.getBoolean(MeasurementInterface.INTERNAL_STORAGE) - val updatesResult = if (internal) { - StorageHandler.createFileInInternalFolder(context, folder, UPDATES_FILE_NAME) + fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { + val updatesResult = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, UPDATES_FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", UPDATES_FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", UPDATES_FILE_NAME) } return updatesResult.flatMap { updates -> updatesOutput = updates - val transitionsResult = if (internal) { - StorageHandler.createFileInInternalFolder(context, folder, TRANSITIONS_FILE_NAME) + val transitionsResult = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, TRANSITIONS_FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", TRANSITIONS_FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", TRANSITIONS_FILE_NAME) } transitionsResult.onFailure { - appResult(AppError.Kind.STORAGE, "Close incomplete activity measurement") { updates.close() } + appResult(AppErrorCode.STORAGE, "Close incomplete activity measurement") { updates.close() } }.flatMap { transitions -> transitionsOutput = transitions initializeResources(context) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize activity recognition") + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") } - private fun initializeResources(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun initializeResources(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Initialize activity recognition resources", ) { updatesOutput?.write( @@ -97,11 +97,7 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme ) transitionsOutput?.write("t_nanos;activity;enter_exit\n".toByteArray()) val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } - if (Build.VERSION.SDK_INT >= 33) { - context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(receiver, filter) - } + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) receiverRegistered = true client = ActivityRecognition.getClient(context) updatesPendingIntent = PendingIntent.getBroadcast( @@ -118,43 +114,42 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme ) } - override fun startMeasurement(context: Context): Result { - if (Build.VERSION.SDK_INT >= 29 && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACTIVITY_RECOGNITION, - ) != PackageManager.PERMISSION_GRANTED - ) { - return Result.failure(AppError(AppError.Kind.PERMISSION, "Start activity recognition")) + @SuppressLint("MissingPermission") + fun start(context: Context): AppResult { + if (!hasPermission(context)) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) } - return appResult(AppError.Kind.MEASUREMENT, "Start activity recognition") { + return appResult(AppErrorCode.MEASUREMENT, "Start activity recognition") { updatesPendingIntent?.let { client?.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, it) ?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request activity updates", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request activity updates", error) } } transitionsPendingIntent?.let { client?.requestActivityTransitionUpdates(ActivityTransitionRequest(ACTIVITY_TRANSITIONS), it) ?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Request activity transitions", error) + AppError.from(AppErrorCode.MEASUREMENT, "Request activity transitions", error) } } } } - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + @SuppressLint("MissingPermission") + private fun pause(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause activity recognition", ) { - updatesPendingIntent?.let { - client?.removeActivityUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove activity updates", error) + if (hasPermission(context)) { + updatesPendingIntent?.let { + client?.removeActivityUpdates(it)?.addOnFailureListener { error -> + AppError.from(AppErrorCode.MEASUREMENT, "Remove activity updates", error) + } } - } - transitionsPendingIntent?.let { - client?.removeActivityTransitionUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Remove activity transitions", error) + transitionsPendingIntent?.let { + client?.removeActivityTransitionUpdates(it)?.addOnFailureListener { error -> + AppError.from(AppErrorCode.MEASUREMENT, "Remove activity transitions", error) + } } } if (receiverRegistered) { @@ -163,34 +158,40 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) : Measureme receiverRegistered = false } - override suspend fun saveMeasurement(context: Context): Result { - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Close activity updates") { + private suspend fun save(): AppResult { + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Close activity updates") { updatesOutput?.close() } - results += appResult(AppError.Kind.STORAGE, "Close activity transitions") { + results += appResult(AppErrorCode.STORAGE, "Close activity transitions") { transitionsOutput?.close() } - writeFailure?.let { results += Result.failure(it) } + writeFailure?.let { results += AppResult.failure(it) } updatesOutput = null transitionsOutput = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save activity recognition") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save activity recognition") } - override suspend fun onDestroyMeasurement(context: Context): Result { - val results = listOf(pauseMeasurement(context), saveMeasurement(context)) + suspend fun stop(context: Context): AppResult { + val results = listOf(pause(context), save()) updatesPendingIntent = null transitionsPendingIntent = null client = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop activity recognition") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop activity recognition") } private inline fun recordWriteFailure(operation: String, block: () -> Unit) { if (writeFailure != null) return - appResult(AppError.Kind.STORAGE, operation, block).onFailure { writeFailure = it as AppError } + appResult(AppErrorCode.STORAGE, operation, block).onFailure { writeFailure = it } } + private fun hasPermission(context: Context): Boolean = Build.VERSION.SDK_INT < 29 || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACTIVITY_RECOGNITION, + ) == PackageManager.PERMISSION_GRANTED + private companion object { const val ACTION_UPDATE = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_UPDATE" const val ACTION_TRANSITION = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt index c16d73d..d2c3fa5 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -4,7 +4,8 @@ import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError @@ -39,11 +40,11 @@ class ExtraInfoHandler { triggeredAlarms += timestampMillis } - fun write(context: Context): Result { - val active = config ?: return Result.success(Unit) - if (written) return Result.success(Unit) + fun write(context: Context): AppResult { + val active = config ?: return AppResult.success(Unit) + if (written) return AppResult.success(Unit) written = true - val jsonResult = appResult(AppError.Kind.MEASUREMENT, "Build measurement metadata") { + val jsonResult = appResult(AppErrorCode.MEASUREMENT, "Build measurement metadata") { JSONObject().apply { put("millis", startedAtMillis) put("nanos", startedAtNanos) @@ -83,11 +84,11 @@ class ExtraInfoHandler { StorageHandler.createFileInFolder(context, active.folderName, "application/json", EXTRA_FILE) } stream.flatMap { output -> - appResult(AppError.Kind.STORAGE, "Write measurement metadata file") { + appResult(AppErrorCode.STORAGE, "Write measurement metadata file") { output.use { it.write(json.toString(2).toByteArray()) } } } - }.withAppError(AppError.Kind.MEASUREMENT, "Write measurement metadata") + }.withAppError(AppErrorCode.MEASUREMENT, "Write measurement metadata") } private fun sensorRanges(context: Context, sensorIds: IntArray): JSONArray { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt index b3dbc91..a073eb4 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt @@ -2,18 +2,16 @@ package com.motionapps.sensorservices.handlers.measurements import android.content.Context import android.location.Location -import android.os.Bundle import com.google.android.gms.location.LocationAvailability import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError import com.motionapps.sensorservices.handlers.GPSHandler import com.motionapps.sensorservices.handlers.StorageHandler -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface.Companion.FOLDER_NAME -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface.Companion.INTERNAL_STORAGE -import com.motionapps.sensorservices.services.MeasurementService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.OutputStream @@ -23,9 +21,7 @@ import java.io.OutputStream * * @property gpsHandler - manages access to GPS */ -class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : - MeasurementInterface, - GPSHandler.OnLocationChangedCallback { +class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandler.OnLocationChangedCallback { private var outputStream: OutputStream? = null private var writeFailure: AppError? = null @@ -37,29 +33,35 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * @param context * @param params - from the service */ - override fun initMeasurement(context: Context, params: Bundle): Result { + fun prepare( + context: Context, + folderName: String, + useInternalStorage: Boolean, + intervalSeconds: Int, + minimumDistanceMeters: Int, + ): AppResult { gpsHandler.configure( - intervalSeconds = params.getInt(MeasurementService.GPS_INTERVAL_SECONDS, 10), - minDistanceMeters = params.getInt(MeasurementService.GPS_DISTANCE_METERS, 20), + intervalSeconds = intervalSeconds, + minDistanceMeters = minimumDistanceMeters, ) - val stream = if (params.getBoolean(INTERNAL_STORAGE)) { + val stream = if (useInternalStorage) { StorageHandler.createFileInInternalFolder( context, - params.getString(FOLDER_NAME).orEmpty(), + folderName, "gps.csv", ) } else { StorageHandler.createFileInFolder( context, - params.getString(FOLDER_NAME).orEmpty(), + folderName, "csv", "gps.csv", ) } return stream.flatMap { output -> outputStream = output - appResult(AppError.Kind.STORAGE, "Write GPS header") { output.write(header.toByteArray()) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize GPS measurement") + appResult(AppErrorCode.STORAGE, "Write GPS header") { output.write(header.toByteArray()) } + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") } /** @@ -83,31 +85,31 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * @param context */ - override fun startMeasurement(context: Context): Result = gpsHandler.addCallback(context, this) - .withAppError(AppError.Kind.MEASUREMENT, "Start GPS") + fun start(context: Context): AppResult = gpsHandler.addCallback(context, this) + .withAppError(AppErrorCode.MEASUREMENT, "Start GPS") /** * turns off the GPS * * @param context */ - override fun pauseMeasurement(context: Context): Result = gpsHandler.gpsOff() - .withAppError(AppError.Kind.MEASUREMENT, "Pause GPS") + private fun pause(): AppResult = gpsHandler.gpsOff() + .withAppError(AppErrorCode.MEASUREMENT, "Pause GPS") /** * outputStream is saved and closed * * @param context */ - override suspend fun saveMeasurement(context: Context): Result { + private suspend fun save(): AppResult { val stream = outputStream - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Flush GPS measurement") { stream?.flush() } - writeFailure?.let { results += Result.failure(it) } - results += appResult(AppError.Kind.STORAGE, "Close GPS measurement") { stream?.close() } + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Flush GPS measurement") { stream?.flush() } + writeFailure?.let { results += AppResult.failure(it) } + results += appResult(AppErrorCode.STORAGE, "Close GPS measurement") { stream?.close() } outputStream = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save GPS measurement") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save GPS measurement") } /** @@ -115,10 +117,10 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : * * @param context */ - override suspend fun onDestroyMeasurement(context: Context): Result = listOf( - withContext(Dispatchers.Main) { pauseMeasurement(context) }, - withContext(Dispatchers.IO) { saveMeasurement(context) }, - ).combineAppResults(AppError.Kind.MEASUREMENT, "Stop GPS measurement") + suspend fun stop(): AppResult = listOf( + withContext(Dispatchers.Main) { pause() }, + withContext(Dispatchers.IO) { save() }, + ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop GPS measurement") /** * called on GPS change @@ -127,9 +129,9 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : */ override fun onLocationChanged(location: Location?) { location?.let { loc: Location -> - appResult(AppError.Kind.STORAGE, "Write GPS sample") { + appResult(AppErrorCode.STORAGE, "Write GPS sample") { outputStream?.write(createLocationStamp(loc).toByteArray()) - }.onFailure { writeFailure = it as AppError } + }.onFailure { writeFailure = it } } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt deleted file mode 100644 index 2b1cfbd..0000000 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.motionapps.sensorservices.handlers.measurements - -import android.content.Context -import android.os.Bundle - -/** - * basic methods, which handlers have to call them at once with service lifecycle events - * - */ -interface MeasurementInterface { - - fun initMeasurement(context: Context, params: Bundle): Result - fun startMeasurement(context: Context): Result - fun pauseMeasurement(context: Context): Result - suspend fun saveMeasurement(context: Context): Result - suspend fun onDestroyMeasurement(context: Context): Result - - companion object { - // keys for the bundle in service to handlers - const val FOLDER_NAME = "FOLDER_NAME" - const val SENSOR_ID = "SENSOR_ID" - const val SENSOR_SPEED = "SENSOR_SPEED" - const val INTERNAL_STORAGE = "INTERNAL_STORAGE" - } -} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt index 0b5a527..e465989 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt @@ -2,8 +2,9 @@ package com.motionapps.sensorservices.handlers.measurements import android.content.Context import android.hardware.SensorManager -import android.os.Bundle import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -12,37 +13,48 @@ import com.motionapps.sensorservices.handlers.StorageHandler import com.motionapps.sensorservices.types.SensorHolder import com.motionapps.sensorservices.types.SensorSpec -class SensorMeasurement : MeasurementInterface { +class SensorMeasurement { private val holders = mutableListOf() private var samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST - override fun initMeasurement(context: Context, params: Bundle): Result { - samplingPeriod = params.getInt(MeasurementInterface.SENSOR_SPEED) - return (params.getIntArray(MeasurementInterface.SENSOR_ID) ?: intArrayOf()).fold( - Result.success(Unit), + fun prepare( + context: Context, + folderName: String, + useInternalStorage: Boolean, + sensorTypes: Set, + samplingPeriod: Int, + ): AppResult { + this.samplingPeriod = samplingPeriod + return sensorTypes.sorted().fold( + AppResult.success(Unit), ) { result, sensorType -> result.flatMap { - createHolder(context, params, sensorType).map { holder -> + createHolder(context, folderName, useInternalStorage, sensorType).map { holder -> holder?.let(holders::add) Unit } } } - .withAppError(AppError.Kind.MEASUREMENT, "Initialize sensors") + .withAppError(AppErrorCode.MEASUREMENT, "Initialize sensors") } - private fun createHolder(context: Context, params: Bundle, sensorType: Int): Result { - val spec = SensorSpec.fromType(sensorType) ?: return Result.success(null) - val stream = if (params.getBoolean(MeasurementInterface.INTERNAL_STORAGE)) { + private fun createHolder( + context: Context, + folderName: String, + useInternalStorage: Boolean, + sensorType: Int, + ): AppResult { + val spec = SensorSpec.fromType(sensorType) ?: return AppResult.success(null) + val stream = if (useInternalStorage) { StorageHandler.createFileInInternalFolder( context, - params.getString(MeasurementInterface.FOLDER_NAME).orEmpty(), + folderName, spec.fileName, ) } else { StorageHandler.createFileInFolder( context, - params.getString(MeasurementInterface.FOLDER_NAME).orEmpty(), + folderName, "text/csv", spec.fileName, ) @@ -50,49 +62,49 @@ class SensorMeasurement : MeasurementInterface { return stream.map { SensorHolder(spec, it) } } - override fun startMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + fun start(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Access sensor manager", ) { context.getSystemService(SensorManager::class.java) }.flatMap { sensorManager -> - holders.fold(Result.success(Unit)) { result, holder -> + holders.fold(AppResult.success(Unit)) { result, holder -> result.flatMap { val sensor = sensorManager.getDefaultSensor(holder.spec.type) - ?: return@flatMap Result.failure( - AppError(AppError.Kind.MEASUREMENT, "Find sensor ${holder.spec.type}"), + ?: return@flatMap AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, "Find sensor ${holder.spec.type}"), ) - appResult(AppError.Kind.MEASUREMENT, "Register sensor ${holder.spec.type}") { + appResult(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}") { sensorManager.registerListener(holder, sensor, samplingPeriod) }.flatMap { registered -> if (registered) { - Result.success(Unit) + AppResult.success(Unit) } else { - Result.failure( - AppError(AppError.Kind.MEASUREMENT, "Register sensor ${holder.spec.type}"), + AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}"), ) } } } } - }.withAppError(AppError.Kind.MEASUREMENT, "Start sensors") + }.withAppError(AppErrorCode.MEASUREMENT, "Start sensors") - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun pause(context: Context): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause sensors", ) { val sensorManager = context.getSystemService(SensorManager::class.java) holders.forEach(sensorManager::unregisterListener) } - override suspend fun saveMeasurement(context: Context): Result { + private suspend fun save(): AppResult { val results = holders.map { it.close() } holders.clear() - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save sensors") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save sensors") } - override suspend fun onDestroyMeasurement(context: Context): Result = listOf( - pauseMeasurement(context), - saveMeasurement(context), - ).combineAppResults(AppError.Kind.MEASUREMENT, "Stop sensors") + suspend fun stop(context: Context): AppResult = listOf( + pause(context), + save(), + ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop sensors") } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt index ad827cb..220a4a7 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt @@ -5,8 +5,9 @@ import android.hardware.Sensor import android.hardware.SensorManager import android.hardware.TriggerEvent import android.hardware.TriggerEventListener -import android.os.Bundle import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap @@ -15,67 +16,64 @@ import com.motionapps.sensorservices.handlers.StorageHandler import java.io.OutputStream /** Handles Android's one-shot significant-motion trigger and re-arms it after every event. */ -class SignificantMotion : - TriggerEventListener(), - MeasurementInterface { +class SignificantMotion : TriggerEventListener() { private var sensorManager: SensorManager? = null private var sensor: Sensor? = null private var output: OutputStream? = null private var writeFailure: AppError? = null - override fun initMeasurement(context: Context, params: Bundle): Result { + fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { sensorManager = context.getSystemService(SensorManager::class.java) sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) - val folder = params.getString(MeasurementInterface.FOLDER_NAME).orEmpty() - val stream = if (params.getBoolean(MeasurementInterface.INTERNAL_STORAGE)) { - StorageHandler.createFileInInternalFolder(context, folder, FILE_NAME) + val stream = if (useInternalStorage) { + StorageHandler.createFileInInternalFolder(context, folderName, FILE_NAME) } else { - StorageHandler.createFileInFolder(context, folder, "text/csv", FILE_NAME) + StorageHandler.createFileInFolder(context, folderName, "text/csv", FILE_NAME) } return stream.flatMap { opened -> output = opened - appResult(AppError.Kind.STORAGE, "Write significant motion header") { + appResult(AppErrorCode.STORAGE, "Write significant motion header") { opened.write("t_unix;event\n".toByteArray()) } - }.withAppError(AppError.Kind.MEASUREMENT, "Initialize significant motion") + }.withAppError(AppErrorCode.MEASUREMENT, "Initialize significant motion") } - override fun startMeasurement(context: Context): Result = if (arm()) { - Result.success(Unit) + fun start(): AppResult = if (arm()) { + AppResult.success(Unit) } else { - Result.failure(AppError(AppError.Kind.MEASUREMENT, "Start significant motion")) + AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Start significant motion")) } - override fun pauseMeasurement(context: Context): Result = appResult( - AppError.Kind.MEASUREMENT, + private fun pause(): AppResult = appResult( + AppErrorCode.MEASUREMENT, "Pause significant motion", ) { sensor?.let { sensorManager?.cancelTriggerSensor(this, it) } } - override suspend fun saveMeasurement(context: Context): Result { - val results = mutableListOf>() - results += appResult(AppError.Kind.STORAGE, "Close significant motion") { output?.close() } - writeFailure?.let { results += Result.failure(it) } + private suspend fun save(): AppResult { + val results = mutableListOf>() + results += appResult(AppErrorCode.STORAGE, "Close significant motion") { output?.close() } + writeFailure?.let { results += AppResult.failure(it) } output = null writeFailure = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Save significant motion") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save significant motion") } - override suspend fun onDestroyMeasurement(context: Context): Result { - val results = listOf(pauseMeasurement(context), saveMeasurement(context)) + suspend fun stop(): AppResult { + val results = listOf(pause(), save()) sensor = null sensorManager = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop significant motion") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop significant motion") } override fun onTrigger(event: TriggerEvent?) { event?.values?.firstOrNull()?.let { value -> - appResult(AppError.Kind.STORAGE, "Write significant motion") { + appResult(AppErrorCode.STORAGE, "Write significant motion") { output?.write("${System.currentTimeMillis()};$value\n".toByteArray()) - }.onFailure { writeFailure = it as AppError } + }.onFailure { writeFailure = it } } - if (!arm()) AppError(AppError.Kind.MEASUREMENT, "Re-arm significant motion") + if (!arm()) AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion") } private fun arm(): Boolean = sensor?.let { sensorManager?.requestTriggerSensor(this, it) } == true diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt index f110b51..1f9ab0d 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt @@ -9,6 +9,7 @@ import javax.inject.Inject class MeasurementIntentFactory @Inject constructor(@ApplicationContext private val context: Context) { fun create(request: MeasurementLaunchRequest): Intent = Intent(context, MeasurementService::class.java).apply { + putExtra(MeasurementService.SESSION_ID, request.sessionId) putExtra(MeasurementService.FOLDER_NAME, request.folderName) putExtra(MeasurementService.INTERNAL_STORAGE, request.useInternalStorage) putExtra(MeasurementService.ANDROID_SENSORS, request.sensorIds.toIntArray()) @@ -26,7 +27,6 @@ class MeasurementIntentFactory @Inject constructor(@ApplicationContext private v putExtra(MeasurementService.ACTIVITY_RECOGNITION, request.activityRecognition) putExtra(MeasurementService.ACTIVITY_RECOGNITION_PERIOD_SECONDS, request.activityRecognitionPeriodSeconds) putExtra(MeasurementService.SIGNIFICANT_MOTION, request.significantMotion) - putExtra(MeasurementService.CONTROLS_WEAR_MEASUREMENT, request.controlsWearMeasurement) } fun newFolderName(customName: String = "", measurementType: String = "ENDLESS"): String { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt index 638bdde..73c9115 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt @@ -1,6 +1,7 @@ package com.motionapps.sensorservices.intent data class MeasurementLaunchRequest( + val sessionId: String = java.util.UUID.randomUUID().toString(), val folderName: String, val useInternalStorage: Boolean, val sensorIds: Set, @@ -18,5 +19,4 @@ data class MeasurementLaunchRequest( val activityRecognition: Boolean = false, val activityRecognitionPeriodSeconds: Int = 30, val significantMotion: Boolean = false, - val controlsWearMeasurement: Boolean = false, ) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt new file mode 100644 index 0000000..6d55c38 --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt @@ -0,0 +1,176 @@ +package com.motionapps.sensorservices.serviceController + +import android.content.Context +import android.media.AudioManager +import android.media.ToneGenerator +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.appResult +import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.recording.RecordingSource +import com.motionapps.sensorbox.recording.RecordingSourceSpec +import com.motionapps.sensorbox.recording.RecordingSourceType +import com.motionapps.sensorservices.handlers.GPSHandler +import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionMeasurement +import com.motionapps.sensorservices.handlers.measurements.ExtraInfoHandler +import com.motionapps.sensorservices.handlers.measurements.GPSMeasurement +import com.motionapps.sensorservices.handlers.measurements.SensorMeasurement +import com.motionapps.sensorservices.handlers.measurements.SignificantMotion + +class AndroidRecordingSources(private val context: Context, private val config: MeasurementConfig) { + private val artifacts = SessionArtifacts(context, config) + + val sources: List = listOf( + SessionSource(artifacts), + SensorSource(context, config), + GpsSource(context, config), + ActivitySource(context, config), + SignificantMotionSource(context, config), + ) + + fun annotate(timestampMillis: Long, text: String): AppResult = artifacts.annotate(timestampMillis, text) + + fun playAlarm(): AppResult = artifacts.playAlarm() +} + +private class SessionSource(private val artifacts: SessionArtifacts) : RecordingSource { + override val type = RecordingSourceType.SESSION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.Session) artifacts.prepare() else invalidSpec(type) + + override suspend fun start(): AppResult = AppResult.success(Unit) + + override suspend fun stop(): AppResult = artifacts.stop() +} + +private class SensorSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private val measurement = SensorMeasurement() + override val type = RecordingSourceType.SENSOR + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.Sensors) { + measurement.prepare( + context = context, + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + sensorTypes = spec.sensorTypes, + samplingPeriod = spec.samplingPeriod, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start(context) + + override suspend fun stop(): AppResult = measurement.stop(context) +} + +private class GpsSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private val measurement = GPSMeasurement(GPSHandler()) + override val type = RecordingSourceType.GPS + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { + measurement.prepare( + context = context, + folderName = config.folderName, + useInternalStorage = config.useInternalStorage, + intervalSeconds = spec.intervalSeconds, + minimumDistanceMeters = spec.minimumDistanceMeters, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start(context) + + override suspend fun stop(): AppResult = measurement.stop() +} + +private class ActivitySource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { + private var measurement: ActivityRecognitionMeasurement? = null + override val type = RecordingSourceType.ACTIVITY_RECOGNITION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.ActivityRecognition) { + ActivityRecognitionMeasurement(spec.periodSeconds).also { measurement = it }.prepare( + context, + config.folderName, + config.useInternalStorage, + ) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement?.start(context) + ?: invalidSpec(type) + + override suspend fun stop(): AppResult { + val result = measurement?.stop(context) ?: AppResult.success(Unit) + measurement = null + return result + } +} + +private class SignificantMotionSource(private val context: Context, private val config: MeasurementConfig) : + RecordingSource { + private val measurement = SignificantMotion() + override val type = RecordingSourceType.SIGNIFICANT_MOTION + + override suspend fun prepare(spec: RecordingSourceSpec): AppResult = + if (spec is RecordingSourceSpec.SignificantMotion) { + measurement.prepare(context, config.folderName, config.useInternalStorage) + } else { + invalidSpec(type) + } + + override suspend fun start(): AppResult = measurement.start() + + override suspend fun stop(): AppResult = measurement.stop() +} + +private class SessionArtifacts(private val context: Context, private val config: MeasurementConfig) { + private val extraInfo = ExtraInfoHandler() + private var toneGenerator: ToneGenerator? = null + + fun prepare(): AppResult { + val directory = if (config.useInternalStorage) { + StorageHandler.createInternalStorageMeasurementFolder(context, config.folderName) + } else { + StorageHandler.createFolderMeasurement(context, config.folderName) + } + return directory.onSuccess { extraInfo.start(config) } + } + + fun annotate(timestampMillis: Long, text: String): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Add measurement annotation", + ) { + extraInfo.annotate(timestampMillis, text) + } + + fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { + extraInfo.alarmTriggered() + val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } + tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) + } + + fun stop(): AppResult { + val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { + toneGenerator?.release() + toneGenerator = null + } + return listOf(releaseTone, extraInfo.write(context)) + .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") + } + + private companion object { + const val ALARM_DURATION_MILLIS = 1_000 + } +} + +private fun invalidSpec(type: RecordingSourceType): AppResult = AppResult.failure( + AppError(AppErrorCode.VALIDATION, "Prepare $type recording source"), +) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt index 61fcc48..beea66c 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt @@ -5,6 +5,7 @@ import android.hardware.SensorManager import com.motionapps.sensorservices.services.MeasurementService data class MeasurementConfig( + val sessionId: String, val folderName: String, val useInternalStorage: Boolean, val sensorIds: IntArray, @@ -22,10 +23,10 @@ data class MeasurementConfig( val activityRecognition: Boolean, val activityRecognitionPeriodSeconds: Int, val significantMotion: Boolean, - val controlsWearMeasurement: Boolean, ) { companion object { fun from(intent: Intent): MeasurementConfig = MeasurementConfig( + sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), sensorIds = intent.getIntArrayExtra(MeasurementService.ANDROID_SENSORS) ?: intArrayOf(), @@ -53,7 +54,6 @@ data class MeasurementConfig( 30, ).coerceAtLeast(1), significantMotion = intent.getBooleanExtra(MeasurementService.SIGNIFICANT_MOTION, false), - controlsWearMeasurement = intent.getBooleanExtra(MeasurementService.CONTROLS_WEAR_MEASUREMENT, false), ) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt index 7032964..6cd0143 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt @@ -1,124 +1,58 @@ package com.motionapps.sensorservices.serviceController import android.content.Context -import android.media.AudioManager -import android.media.ToneGenerator -import android.os.Bundle -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.appResult -import com.motionapps.sensorbox.core.error.combineAppResults -import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorservices.handlers.GPSHandler -import com.motionapps.sensorservices.handlers.StorageHandler -import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionMeasurement -import com.motionapps.sensorservices.handlers.measurements.ExtraInfoHandler -import com.motionapps.sensorservices.handlers.measurements.GPSMeasurement -import com.motionapps.sensorservices.handlers.measurements.MeasurementInterface -import com.motionapps.sensorservices.handlers.measurements.SensorMeasurement -import com.motionapps.sensorservices.handlers.measurements.SignificantMotion -import com.motionapps.sensorservices.services.MeasurementService - -class ServiceController { - private val sensorMeasurement = SensorMeasurement() - private val gpsMeasurement = GPSMeasurement(GPSHandler()) - private var activeConfig: MeasurementConfig? = null - private val extraInfo = ExtraInfoHandler() - private var activityRecognition: ActivityRecognitionMeasurement? = null - private val significantMotion = SignificantMotion() - private var toneGenerator: ToneGenerator? = null - - fun start(context: Context, config: MeasurementConfig): Result { - var result = createMeasurementDirectory(context, config).flatMap { - appResult(AppError.Kind.MEASUREMENT, "Initialize measurement session") { - activeConfig = config - extraInfo.start(config) - } +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.recording.RecordingClock +import com.motionapps.sensorbox.recording.RecordingDelay +import com.motionapps.sensorbox.recording.RecordingEngine +import com.motionapps.sensorbox.recording.RecordingEvent +import com.motionapps.sensorbox.recording.RecordingPlan +import com.motionapps.sensorbox.recording.RecordingSessionId +import com.motionapps.sensorbox.recording.RecordingSourceSpec +import com.motionapps.sensorbox.recording.RecordingStopReason +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharedFlow + +class ServiceController(context: Context, private val config: MeasurementConfig, scope: CoroutineScope) { + private val androidSources = AndroidRecordingSources(context, config) + private val sessionId = RecordingSessionId(config.sessionId) + private val engine = RecordingEngine( + sources = androidSources.sources, + scope = scope, + clock = RecordingClock(System::currentTimeMillis), + delay = RecordingDelay { durationMillis -> delay(durationMillis) }, + ) + + val events: SharedFlow = engine.events + + suspend fun prepareAndCommit(): AppResult { + val plan = config.toRecordingPlan(sessionId) + return when (val prepared = engine.prepare(plan)) { + is AppResult.Success -> engine.commit(sessionId) + is AppResult.Failure -> prepared } - if (config.sensorIds.isNotEmpty()) result = result.flatMap { startSensors(context, config) } - if (config.includesGps) result = result.flatMap { startGps(context, config) } - if (config.activityRecognition) result = result.flatMap { startActivityRecognition(context, config) } - if (config.significantMotion) result = result.flatMap { startSignificantMotion(context, config) } - return result.withAppError(AppError.Kind.MEASUREMENT, "Start measurement controller") } - suspend fun stop(context: Context): Result { - val config = activeConfig ?: return Result.success(Unit) - val results = mutableListOf>() - if (config.sensorIds.isNotEmpty()) results += sensorMeasurement.onDestroyMeasurement(context) - if (config.includesGps) results += gpsMeasurement.onDestroyMeasurement(context) - activityRecognition?.let { results += it.onDestroyMeasurement(context) } - activityRecognition = null - if (config.significantMotion) results += significantMotion.onDestroyMeasurement(context) - results += appResult(AppError.Kind.MEASUREMENT, "Release alarm") { - toneGenerator?.release() - Unit - } - toneGenerator = null - results += extraInfo.write(context) - activeConfig = null - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Stop measurement controller") - } + suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(sessionId, reason) - fun annotate(timestampMillis: Long, text: String): Result = appResult( - AppError.Kind.MEASUREMENT, - "Add measurement annotation", - ) { - extraInfo.annotate(timestampMillis, text) - } - - fun playAlarm(): Result = appResult(AppError.Kind.MEASUREMENT, "Play measurement alarm") { - extraInfo.alarmTriggered() - val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } - tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) - } - - private fun createMeasurementDirectory(context: Context, config: MeasurementConfig): Result = - if (config.useInternalStorage) { - StorageHandler.createInternalStorageMeasurementFolder(context, config.folderName) - } else { - StorageHandler.createFolderMeasurement(context, config.folderName) - } + fun annotate(timestampMillis: Long, text: String): AppResult = androidSources.annotate(timestampMillis, text) - private fun startSensors(context: Context, config: MeasurementConfig): Result { - val params = baseParams(config).apply { - putIntArray(MeasurementInterface.SENSOR_ID, config.sensorIds) - putInt(MeasurementInterface.SENSOR_SPEED, config.sensorSamplingPeriod) - } - return sensorMeasurement.initMeasurement(context, params) - .flatMap { sensorMeasurement.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured sensors") - } + fun playAlarm(): AppResult = androidSources.playAlarm() - private fun startGps(context: Context, config: MeasurementConfig): Result { - val params = baseParams(config).apply { - putInt(MeasurementService.GPS_INTERVAL_SECONDS, config.gpsIntervalSeconds) - putInt(MeasurementService.GPS_DISTANCE_METERS, config.gpsMinDistanceMeters) + private fun MeasurementConfig.toRecordingPlan(sessionId: RecordingSessionId): RecordingPlan { + val specs = buildList { + add(RecordingSourceSpec.Session) + if (sensorIds.isNotEmpty()) add(RecordingSourceSpec.Sensors(sensorIds.toSet(), sensorSamplingPeriod)) + if (includesGps) add(RecordingSourceSpec.Gps(gpsIntervalSeconds, gpsMinDistanceMeters)) + if (activityRecognition) add(RecordingSourceSpec.ActivityRecognition(activityRecognitionPeriodSeconds)) + if (significantMotion) add(RecordingSourceSpec.SignificantMotion) } - return gpsMeasurement.initMeasurement(context, params) - .flatMap { gpsMeasurement.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured GPS") - } - - private fun startActivityRecognition(context: Context, config: MeasurementConfig): Result { - val handler = ActivityRecognitionMeasurement(config.activityRecognitionPeriodSeconds) - return handler.initMeasurement(context, baseParams(config)) - .flatMap { handler.startMeasurement(context) } - .onSuccess { activityRecognition = handler } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured activity recognition") - } - - private fun startSignificantMotion(context: Context, config: MeasurementConfig): Result = - significantMotion.initMeasurement(context, baseParams(config)) - .flatMap { significantMotion.startMeasurement(context) } - .withAppError(AppError.Kind.MEASUREMENT, "Start configured significant motion") - - private fun baseParams(config: MeasurementConfig) = Bundle().apply { - putString(MeasurementInterface.FOLDER_NAME, config.folderName) - putBoolean(MeasurementInterface.INTERNAL_STORAGE, config.useInternalStorage) - } - - private companion object { - const val ALARM_DURATION_MILLIS = 1_000 + return RecordingPlan( + sessionId = sessionId, + sources = specs, + startAtEpochMillis = startAtEpochMillis, + durationMillis = durationMillis, + ) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt index 2e288eb..7eb3a98 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt @@ -11,19 +11,17 @@ import android.os.Build import android.os.IBinder import android.os.PowerManager import android.os.SystemClock -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults -import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.recording.RecordingEvent +import com.motionapps.sensorbox.recording.RecordingStopReason import com.motionapps.sensorservices.serviceController.MeasurementConfig import com.motionapps.sensorservices.serviceController.ServiceController import com.motionapps.sensorservices.session.MeasurementSessionState import com.motionapps.sensorservices.session.MeasurementSessionStore -import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec +import com.motionapps.sensorservices.session.MeasurementStopReason import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +30,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject @AndroidEntryPoint @@ -39,22 +38,18 @@ class MeasurementService : Service() { @Inject lateinit var sessionStore: MeasurementSessionStore - @Inject - lateinit var sendWearMessage: SendWearMessageUseCase - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val controller = ServiceController() + private var controller: ServiceController? = null private var activeConfig: MeasurementConfig? = null + private var eventJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null - private var isStopping = false private var batteryReceiverRegistered = false - private var startJob: Job? = null - private var durationJob: Job? = null private var alarmJobs: List = emptyList() + private var isFinishing = false private val lowBatteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_LOW) stopMeasurement() + if (intent?.action == Intent.ACTION_BATTERY_LOW) requestStop(RecordingStopReason.LOW_BATTERY) } } @@ -62,55 +57,123 @@ class MeasurementService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> stopMeasurement() + ACTION_STOP -> requestStop(RecordingStopReason.USER_REQUEST) - ACTION_ANNOTATE -> controller.annotate( + ACTION_ANNOTATE -> controller?.annotate( intent.getLongExtra(ANNOTATION_TIME, System.currentTimeMillis()), intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), ) - else -> intent?.takeIf { activeConfig == null }?.let { startIntent -> - startMeasurement(startIntent).onFailure { stopMeasurement() } - } + else -> intent?.takeIf { activeConfig == null }?.let(::startRecordingHost) } - return START_NOT_STICKY } - private fun startMeasurement(intent: Intent): Result = appResult( - AppError.Kind.MEASUREMENT, - "Start measurement service", - ) { - val config = MeasurementConfig.from(intent) - activeConfig = config - promoteToForeground(config) - publishRunningSession(config) - configureRuntimeResources(config) - scheduleMeasurementStart(config) + private fun startRecordingHost(intent: Intent) { + appResult(AppErrorCode.MEASUREMENT, "Start recording foreground host") { + val config = MeasurementConfig.from(intent) + require(config.sessionId.isNotBlank()) { "Recording session ID is missing" } + activeConfig = config + isFinishing = false + promoteToForeground(config) + configureRuntimeResources(config) + val serviceController = ServiceController(this, config, serviceScope) + controller = serviceController + observeEngine(serviceController) + serviceScope.launch { + val result = serviceController.prepareAndCommit() + if (result.isFailure && !isFinishing) { + finishRecording(MeasurementStopReason.SOURCE_FAILURE, result) + } + } + }.onFailure { error -> + serviceScope.launch { + finishRecording( + MeasurementStopReason.SOURCE_FAILURE, + AppResult.failure(error), + ) + } + } } - private fun scheduleMeasurementStart(config: MeasurementConfig) { - startJob = serviceScope.launch { - delay((config.startAtEpochMillis - System.currentTimeMillis()).coerceAtLeast(0L)) - if (controller.start(this@MeasurementService, config).isFailure) { - stopMeasurement() - return@launch - } - scheduleAlarms(config) - if (config.durationMillis > 0L) { - durationJob = serviceScope.launch { - delay(config.durationMillis) - stopMeasurement() + private fun observeEngine(serviceController: ServiceController) { + eventJob?.cancel() + eventJob = serviceScope.launch { + serviceController.events.collect { event -> + when (event) { + is RecordingEvent.RecordingStarted -> onRecordingStarted(event) + + is RecordingEvent.RecordingStartRejected -> finishRecording( + MeasurementStopReason.SOURCE_FAILURE, + AppResult.failure(event.error), + ) + + is RecordingEvent.RecordingStopped -> finishRecording( + event.reason.toMeasurementReason(), + event.result, + ) } } } } + private fun onRecordingStarted(event: RecordingEvent.RecordingStarted) { + val config = activeConfig ?: return + sessionStore.markRunning( + MeasurementSessionState.Running( + sessionId = event.sessionId.value, + folderName = config.folderName, + startedAtElapsedRealtime = SystemClock.elapsedRealtime(), + sensorIds = config.sensorIds.toList(), + includesGps = config.includesGps, + ), + ) + scheduleAlarms(config) + } + + private fun requestStop(reason: RecordingStopReason) { + if (isFinishing) return + sessionStore.markStopping() + serviceScope.launch { + val result = controller?.stop(reason) ?: AppResult.success(Unit) + if (result.isFailure && !isFinishing) finishRecording(reason.toMeasurementReason(), result) + } + } + + private suspend fun finishRecording(reason: MeasurementStopReason, engineResult: AppResult) { + if (isFinishing) return + isFinishing = true + val sessionId = activeConfig?.sessionId.orEmpty() + val hostResult = finishHost() + val result = listOf(engineResult, hostResult) + .combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording foreground host") + sessionStore.publishStopped(sessionId, reason, result) + eventJob?.cancel() + eventJob = null + } + + private fun finishHost(): AppResult { + val results = mutableListOf>() + results += releaseRuntimeResources() + activeConfig = null + controller = null + results += appResult(AppErrorCode.MEASUREMENT, "Publish idle measurement state") { + sessionStore.markIdle() + } + results += appResult(AppErrorCode.MEASUREMENT, "Remove measurement notification") { + stopForeground(STOP_FOREGROUND_REMOVE) + } + results += appResult(AppErrorCode.MEASUREMENT, "Stop measurement service instance") { + stopSelf() + } + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording host resources") + } + private fun scheduleAlarms(config: MeasurementConfig) { alarmJobs = config.alarmOffsetsSeconds.distinct().sorted().map { seconds -> serviceScope.launch { delay(seconds * 1_000L) - controller.playAlarm() + controller?.playAlarm() } } } @@ -130,18 +193,6 @@ class MeasurementService : Service() { FOREGROUND_SERVICE_TYPE_HEALTH } - private fun publishRunningSession(config: MeasurementConfig) { - val delayMillis = (config.startAtEpochMillis - System.currentTimeMillis()).coerceAtLeast(0L) - sessionStore.markRunning( - MeasurementSessionState.Running( - folderName = config.folderName, - startedAtElapsedRealtime = SystemClock.elapsedRealtime() + delayMillis, - sensorIds = config.sensorIds.toList(), - includesGps = config.includesGps, - ), - ) - } - private fun configureRuntimeResources(config: MeasurementConfig) { if (config.useWakeLock) acquireWakeLock() if (config.stopOnLowBattery) registerLowBatteryReceiver() @@ -149,9 +200,7 @@ class MeasurementService : Service() { private fun acquireWakeLock() { val powerManager = getSystemService(PowerManager::class.java) - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { - acquire() - } + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { acquire() } } private fun registerLowBatteryReceiver() { @@ -164,77 +213,60 @@ class MeasurementService : Service() { batteryReceiverRegistered = true } - private fun stopMeasurement() { - if (isStopping) return - isStopping = true - sessionStore.markStopping() - serviceScope.launch { - val failures = mutableListOf() - controller.stop(this@MeasurementService).exceptionOrNull()?.let(failures::add) - stopRemoteMeasurement().exceptionOrNull()?.let(failures::add) - finishService().exceptionOrNull()?.let(failures::add) - failures.firstOrNull()?.let { first -> - failures.drop(1).forEach(first::addSuppressed) - AppError.from(AppError.Kind.MEASUREMENT, "Stop measurement service", first) - } - } - } - - private suspend fun stopRemoteMeasurement(): Result { - val config = activeConfig ?: return Result.success(Unit) - if (config.useInternalStorage || !config.controlsWearMeasurement) return Result.success(Unit) - return WearCommandCodec.encode(WearCommand.StopMeasurement).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } - } - - private fun finishService(): Result { - val results = mutableListOf>() - results += releaseRuntimeResources() - activeConfig = null - isStopping = false - results += appResult(AppError.Kind.MEASUREMENT, "Publish idle measurement state") { - sessionStore.markIdle() - } - results += appResult(AppError.Kind.MEASUREMENT, "Remove measurement notification") { - stopForeground(STOP_FOREGROUND_REMOVE) - } - results += appResult(AppError.Kind.MEASUREMENT, "Stop measurement service instance") { - stopSelf() - } - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Finish measurement service") - } - - private fun releaseRuntimeResources(): Result { - val results = mutableListOf>() - startJob?.cancel() - startJob = null - durationJob?.cancel() - durationJob = null + private fun releaseRuntimeResources(): AppResult { alarmJobs.forEach(Job::cancel) alarmJobs = emptyList() - results += appResult(AppError.Kind.MEASUREMENT, "Release measurement wake lock") { + val results = mutableListOf>() + results += appResult(AppErrorCode.MEASUREMENT, "Release measurement wake lock") { wakeLock?.takeIf(PowerManager.WakeLock::isHeld)?.release() + wakeLock = null } - wakeLock = null - results += appResult(AppError.Kind.MEASUREMENT, "Unregister low battery receiver") { + results += appResult(AppErrorCode.MEASUREMENT, "Unregister low battery receiver") { if (batteryReceiverRegistered) unregisterReceiver(lowBatteryReceiver) + batteryReceiverRegistered = false } - batteryReceiverRegistered = false - return results.combineAppResults(AppError.Kind.MEASUREMENT, "Release measurement resources") + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Release recording host resources") } override fun onDestroy() { - if (activeConfig != null && !isStopping) { - AppError(AppError.Kind.MEASUREMENT, "Measurement service destroyed before cleanup") + val config = activeConfig + if (config != null && !isFinishing) { + isFinishing = true + val cleanup = runBlocking(Dispatchers.IO) { + controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) + } + val resources = releaseRuntimeResources() + sessionStore.markIdle() + sessionStore.publishStopped( + config.sessionId, + MeasurementStopReason.SERVICE_DESTROYED, + listOf(cleanup, resources).combineAppResults( + AppErrorCode.MEASUREMENT, + "Destroy recording foreground host", + ), + ) } - releaseRuntimeResources() - appResult(AppError.Kind.MEASUREMENT, "Publish destroyed measurement state") { sessionStore.markIdle() } + eventJob?.cancel() serviceScope.cancel() super.onDestroy() } + private fun RecordingStopReason.toMeasurementReason(): MeasurementStopReason = when (this) { + RecordingStopReason.USER_REQUEST, + RecordingStopReason.PAIRED_ABORT, + -> MeasurementStopReason.USER_REQUEST + + RecordingStopReason.DURATION_EXPIRED -> MeasurementStopReason.DURATION_EXPIRED + + RecordingStopReason.LOW_BATTERY -> MeasurementStopReason.LOW_BATTERY + + RecordingStopReason.SOURCE_FAILURE -> MeasurementStopReason.SOURCE_FAILURE + + RecordingStopReason.PLATFORM_DESTROYED -> MeasurementStopReason.SERVICE_DESTROYED + } + 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" @@ -252,7 +284,6 @@ class MeasurementService : Service() { const val ACTIVITY_RECOGNITION = "ACTIVITY_RECOGNITION" const val ACTIVITY_RECOGNITION_PERIOD_SECONDS = "ACTIVITY_RECOGNITION_PERIOD_SECONDS" const val SIGNIFICANT_MOTION = "SIGNIFICANT_MOTION" - const val CONTROLS_WEAR_MEASUREMENT = "CONTROLS_WEAR_MEASUREMENT" const val ACTION_ANNOTATE = "com.motionapps.sensorbox.action.ANNOTATE_MEASUREMENT" const val ANNOTATION_TIME = "ANNOTATION_TIME" const val ANNOTATION_TEXT = "ANNOTATION_TEXT" diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt index 580a029..d89c7b4 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionState.kt @@ -4,6 +4,7 @@ sealed interface MeasurementSessionState { data object Idle : MeasurementSessionState data class Running( + val sessionId: String, val folderName: String, val startedAtElapsedRealtime: Long, val sensorIds: List, diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt index 0b2684f..9e751b0 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/session/MeasurementSessionStore.kt @@ -1,6 +1,9 @@ package com.motionapps.sensorservices.session +import com.motionapps.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 @@ -9,7 +12,9 @@ import javax.inject.Singleton @Singleton class MeasurementSessionStore @Inject constructor() { private val mutableState = MutableStateFlow(MeasurementSessionState.Idle) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) val state: StateFlow = mutableState.asStateFlow() + val events: SharedFlow = mutableEvents fun markRunning(state: MeasurementSessionState.Running) { mutableState.value = state @@ -22,4 +27,25 @@ class MeasurementSessionStore @Inject constructor() { fun markIdle() { mutableState.value = MeasurementSessionState.Idle } + + fun publishStopped(sessionId: String, reason: MeasurementStopReason, result: AppResult) { + mutableEvents.tryEmit(MeasurementSessionEvent.Stopped(sessionId, reason, result)) + } + + private companion object { + const val EVENT_BUFFER_SIZE = 16 + } +} + +enum class MeasurementStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + SERVICE_DESTROYED, +} + +sealed interface MeasurementSessionEvent { + data class Stopped(val sessionId: String, val reason: MeasurementStopReason, val result: AppResult) : + MeasurementSessionEvent } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt index 12ca4d3..fe3b753 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt @@ -4,6 +4,8 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.suspendAppResult @@ -25,7 +27,7 @@ class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEve writer.append(spec.header) for (sample in samples) writer.appendLine(sample.toCsv(spec.axisCount)) } catch (error: IOException) { - writerFailure = AppError.from(AppError.Kind.STORAGE, "Write ${spec.fileName}", error) + writerFailure = AppError.from(AppErrorCode.STORAGE, "Write ${spec.fileName}", error) samples.close() } } @@ -44,21 +46,21 @@ class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEve ), ) if (result.isFailure && writerFailure == null) { - writerFailure = AppError(AppError.Kind.STORAGE, "Buffer ${spec.fileName}") + writerFailure = AppError(AppErrorCode.STORAGE, "Buffer ${spec.fileName}") } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - suspend fun close(): Result { + suspend fun close(): AppResult { samples.close() - val results = mutableListOf>() - results += suspendAppResult(AppError.Kind.STORAGE, "Finish ${spec.fileName} writer") { writerJob.await() } - writerFailure?.let { results += Result.failure(it) } - results += appResult(AppError.Kind.STORAGE, "Flush ${spec.fileName}") { writer.flush() } - results += appResult(AppError.Kind.STORAGE, "Close ${spec.fileName}") { writer.close() } + val results = mutableListOf>() + results += suspendAppResult(AppErrorCode.STORAGE, "Finish ${spec.fileName} writer") { writerJob.await() } + writerFailure?.let { results += AppResult.failure(it) } + results += appResult(AppErrorCode.STORAGE, "Flush ${spec.fileName}") { writer.flush() } + results += appResult(AppErrorCode.STORAGE, "Close ${spec.fileName}") { writer.close() } scope.cancel() - return results.combineAppResults(AppError.Kind.STORAGE, "Close ${spec.fileName}") + return results.combineAppResults(AppErrorCode.STORAGE, "Close ${spec.fileName}") } } diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt index c0ea808..2d2d40c 100644 --- a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorservices.types import android.hardware.Sensor import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -17,9 +18,9 @@ class SensorHolderErrorTest { val result = holder.close() - val error = result.exceptionOrNull() + val error = result.errorOrNull() assertTrue(error is AppError) - assertEquals(AppError.Kind.STORAGE, (error as AppError).kind) + assertEquals(AppErrorCode.STORAGE, (error as AppError).code) } private class FailingOutputStream : OutputStream() { diff --git a/settings.gradle.kts b/settings.gradle.kts index ea7fb18..756485c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,8 @@ rootProject.name = "SensorBox" include( ":app", ":core", + ":core-common", + ":recording-core", ":sensorservices", ":wear", ":wearoslib", diff --git a/tools/emulator/run_phone_sensor_test.sh b/tools/emulator/run_phone_sensor_test.sh new file mode 100755 index 0000000..50a78e1 --- /dev/null +++ b/tools/emulator/run_phone_sensor_test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +phone_serial="${PHONE_SERIAL:-emulator-5554}" +adb_bin="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb" +test_class="com.motionapps.sensorbox.emulator.PhoneSensorRecordingEmulatorTest" +runner="motionapps.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" +result_file="$(mktemp -t sensorbox-phone-test.XXXXXX)" + +cleanup() { + rm -f "$result_file" +} +trap cleanup EXIT + +cd "$repo_root" +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest +"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/debug/app-debug.apk +"$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +"$adb_bin" -s "$phone_serial" shell pm grant motionapps.sensorbox android.permission.POST_NOTIFICATIONS || true +"$adb_bin" -s "$phone_serial" logcat -c + +"$adb_bin" -s "$phone_serial" shell am instrument -w -r -e class "$test_class" "$runner" >"$result_file" & +test_pid=$! + +sleep 3 +for value in "1.25:2.5:9.5" "-3.0:4.25:8.75" "6.5:-1.5:7.25"; do + "$adb_bin" -s "$phone_serial" emu sensor set acceleration "$value" + sleep 1 +done + +wait "$test_pid" +cat "$result_file" +grep -q "OK (1 test)" "$result_file" diff --git a/tools/emulator/run_wear_sync_test.sh b/tools/emulator/run_wear_sync_test.sh new file mode 100755 index 0000000..fc1f57b --- /dev/null +++ b/tools/emulator/run_wear_sync_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +phone_serial="${PHONE_SERIAL:-emulator-5554}" +wear_serial="${WEAR_SERIAL:-emulator-5556}" +adb_bin="${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}/platform-tools/adb" +phone_test="com.motionapps.sensorbox.emulator.WearToPhoneSyncReceiverEmulatorTest" +wear_test="com.motionapps.sensorbox.emulator.WearToPhoneSyncSenderEmulatorTest" +runner="motionapps.sensorbox.test/androidx.test.runner.AndroidJUnitRunner" +result_directory="$(mktemp -d -t sensorbox-wear-sync.XXXXXX)" +phone_result="$result_directory/phone.txt" +wear_result="$result_directory/wear.txt" + +cleanup() { + rm -rf "$result_directory" +} +trap cleanup EXIT + +install_apks() { + "$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/debug/app-debug.apk + "$adb_bin" -s "$phone_serial" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + "$adb_bin" -s "$wear_serial" install -r wear/build/outputs/apk/debug/wear-debug.apk + "$adb_bin" -s "$wear_serial" install -r wear/build/outputs/apk/androidTest/debug/wear-debug-androidTest.apk +} + +connect_emulators() { + "$adb_bin" -s "$phone_serial" forward tcp:5602 tcp:5601 + "$adb_bin" -s "$wear_serial" reverse tcp:5601 tcp:5602 + "$adb_bin" -s "$wear_serial" shell am broadcast \ + -a com.google.android.gms.wearable.EMULATOR \ + --es operation refresh-emulator-connection >/dev/null +} + +require_paired_emulators() { + 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 +} + +run_receiver() { + "$adb_bin" -s "$phone_serial" shell am instrument -w -r \ + -e class "$phone_test" "$runner" >"$phone_result" & + phone_test_pid=$! +} + +cd "$repo_root" +connect_emulators +require_paired_emulators +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest \ + :wear:assembleDebug :wear:assembleDebugAndroidTest +install_apks +run_receiver +sleep 2 + +"$adb_bin" -s "$wear_serial" shell am instrument -w -r \ + -e class "$wear_test" "$runner" >"$wear_result" +wait "$phone_test_pid" + +cat "$wear_result" +cat "$phone_result" +grep -q "OK (1 test)" "$wear_result" +grep -q "OK (1 test)" "$phone_result" diff --git a/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt index 5f6331c..9b6648c 100644 --- a/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt +++ b/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt @@ -3,6 +3,8 @@ package com.motionapps.sensorbox.emulator import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.domain.sync.SyncWearMeasurementsUseCase import com.motionapps.wearoslib.connectivity.GooglePlayWearConnectionRepository import com.motionapps.wearoslib.files.GooglePlayWearFileTransferClient @@ -41,12 +43,16 @@ class WearToPhoneSyncSenderEmulatorTest { } private suspend fun syncWhenPhoneBecomesReachable(sync: SyncWearMeasurementsUseCase): Int { - var lastFailure: Throwable? = null + var lastFailure: AppError? = null repeat(MAX_ATTEMPTS) { - sync().onSuccess { return it }.onFailure { lastFailure = it } + when (val result = sync()) { + is AppResult.Success -> return result.value + is AppResult.Failure -> lastFailure = result.error + } delay(POLL_INTERVAL_MILLIS) } - throw AssertionError("Phone emulator did not become reachable", lastFailure) + val message = "Phone emulator did not become reachable: ${lastFailure?.code}" + throw AssertionError(message, lastFailure?.cause) } private companion object { diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 6f0248d..d598f3a 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -68,7 +68,7 @@ diff --git a/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt b/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt index b432411..138bf5e 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt @@ -1,13 +1,22 @@ package com.motionapps.sensorbox import android.app.Application -import com.motionapps.sensorbox.core.error.AppDiagnostics +import com.motionapps.sensorbox.communication.WearRecordingSessionObserver +import com.motionapps.sensorbox.core.error.FileDiagnostics import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp class WearSensorBoxApp : Application() { + @Inject + lateinit var diagnostics: FileDiagnostics + + @Inject + lateinit var recordingSessionObserver: WearRecordingSessionObserver + override fun onCreate() { super.onCreate() - AppDiagnostics.install(this) + diagnostics.installUncaughtExceptionHandler() + recordingSessionObserver.start() } } diff --git a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt index 122fa1b..6e96a73 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt @@ -16,7 +16,7 @@ import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.remote.interactions.RemoteActivityHelper import com.motionapps.sensorbox.R -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.presentation.dashboard.WearDashboardEffect import com.motionapps.sensorbox.presentation.dashboard.WearDashboardIntent @@ -66,14 +66,14 @@ class MainActivity : ComponentActivity() { private fun handleEffect(effect: WearDashboardEffect) { when (effect) { is WearDashboardEffect.RequestPermissions -> appResult( - AppError.Kind.PERMISSION, + AppErrorCode.PERMISSION, "Request Wear permissions", ) { permissions.launch(effect.permissions.toTypedArray()) } WearDashboardEffect.OpenPhone -> appResult( - AppError.Kind.EXTERNAL_ACTION, + AppErrorCode.EXTERNAL_ACTION, "Open phone launcher", ) { startActivity(Intent(this, MoveToMain::class.java)) } @@ -89,12 +89,12 @@ class MainActivity : ComponentActivity() { val intent = Intent(Intent.ACTION_VIEW) .addCategory(Intent.CATEGORY_BROWSABLE) .setData(url.toUri()) - appResult(AppError.Kind.EXTERNAL_ACTION, "Request phone browser") { + appResult(AppErrorCode.EXTERNAL_ACTION, "Request phone browser") { RemoteActivityHelper(this).startRemoteActivity(intent) }.onSuccess { request -> request.addListener( { - appResult(AppError.Kind.EXTERNAL_ACTION, "Open phone browser") { request.get() } + appResult(AppErrorCode.EXTERNAL_ACTION, "Open phone browser") { request.get() } .fold( onSuccess = { Toast.makeText(this, R.string.open_phone_browser, Toast.LENGTH_SHORT).show() diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt index 32d786c..1d8f6c4 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt @@ -1,108 +1,24 @@ package com.motionapps.sensorbox.communication -import android.hardware.Sensor import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.suspendAppResult -import com.motionapps.sensorbox.core.error.suspendFlatMap -import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository -import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase -import com.motionapps.sensorbox.domain.measurement.WearMeasurementPermissionUseCase -import com.motionapps.sensorbox.domain.sensors.GetWearSensorsUseCase -import com.motionapps.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec -import com.motionapps.wearoslib.protocol.WearSensorInfo import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint class MsgListener : WearableListenerService() { @Inject - lateinit var measurementControl: WearMeasurementControlUseCase - - @Inject - lateinit var measurementPermissions: WearMeasurementPermissionUseCase - - @Inject - lateinit var preferencesRepository: AppPreferencesRepository - - @Inject - lateinit var getWearSensors: GetWearSensorsUseCase - - @Inject - lateinit var sendWearMessage: SendWearMessageUseCase + lateinit var messageDispatcher: WearMessageDispatcher private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onMessageReceived(messageEvent: MessageEvent) { - if (messageEvent.path != WEAR_MESSAGE_PATH) return - val command = WearCommandCodec.decode(messageEvent.data).fold( - onSuccess = { it }, - onFailure = { return }, - ) - when (command) { - is WearCommand.StartMeasurement -> startMeasurement(command) - WearCommand.StopMeasurement -> measurementControl.stop() - WearCommand.RequestSensorList -> sendSensorList() - WearCommand.SyncMeasurements, WearCommand.LaunchPhone, is WearCommand.SensorList -> Unit - } - } - - private fun sendSensorList() { - serviceScope.launch { - suspendAppResult(AppError.Kind.MEASUREMENT, "Read Wear sensors") { - val sensors = getWearSensors().map { sensor -> - WearSensorInfo(sensor.type, sensor.name, sensor.vendor, sensor.isHeartRate) - } - sensors - }.suspendFlatMap { sensors -> - WearCommandCodec.encode(WearCommand.SensorList(sensors)) - }.suspendFlatMap { payload -> - sendWearMessage( - PHONE_APP_CAPABILITY, - PHONE_MESSAGE_PATH, - payload, - ) - }.withAppError(AppError.Kind.CONNECTIVITY, "Send Wear sensor list") - } - } - - private fun startMeasurement(command: WearCommand.StartMeasurement) { - serviceScope.launch { - suspendAppResult(AppError.Kind.PERMISSION, "Check remote measurement permissions") { - val includesHeartRate = Sensor.TYPE_HEART_RATE in command.sensorIds - measurementPermissions(command.includesGps, includesHeartRate) - }.suspendFlatMap { missingPermissions -> - if (missingPermissions.isNotEmpty()) { - return@suspendFlatMap Result.failure( - AppError(AppError.Kind.PERMISSION, "Start remote Wear measurement"), - ) - } - preferencesRepository.preferences.first().suspendFlatMap { preferences -> - measurementControl.start( - sensorIds = command.sensorIds.toSet(), - includesGps = command.includesGps, - preferences = preferences, - folderName = command.folderName, - startAtEpochMillis = command.startAtEpochMillis, - durationMillis = command.durationMillis, - measurementType = command.measurementType, - ) - } - }.withAppError(AppError.Kind.MEASUREMENT, "Handle remote measurement start") - } + serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) } } override fun onDestroy() { diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt new file mode 100644 index 0000000..56a0ae0 --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt @@ -0,0 +1,38 @@ +package com.motionapps.sensorbox.communication + +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearSessionCommand +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearAcknowledgementInbox @Inject constructor() { + private val acknowledgements = ConcurrentHashMap() + private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) + + fun publish(acknowledgement: WearCommand.Acknowledgement) { + acknowledgements[acknowledgement.key()] = acknowledgement + updates.tryEmit(acknowledgement) + } + + fun clear(sessionId: String, command: WearSessionCommand) { + acknowledgements.remove(Key(sessionId, command)) + } + + suspend fun await(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement { + val key = Key(sessionId, command) + acknowledgements[key]?.let { return it } + return updates.first { acknowledgement -> acknowledgement.key() == key } + } + + private fun WearCommand.Acknowledgement.key() = Key(sessionId, command) + + private data class Key(val sessionId: String, val command: WearSessionCommand) + + private companion object { + const val BUFFER_SIZE = 32 + } +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt new file mode 100644 index 0000000..0333b8f --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt @@ -0,0 +1,43 @@ +package com.motionapps.sensorbox.communication + +import android.hardware.Sensor +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.WearMeasurementPermissionUseCase +import com.motionapps.sensorbox.domain.sensors.GetWearSensorsUseCase +import com.motionapps.wearoslib.protocol.WearRecordingRequest +import com.motionapps.wearoslib.protocol.WearSensorInfo +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +interface WearCommandEnvironment { + suspend fun prepare(request: WearRecordingRequest): AppResult + + fun sensors(): List +} + +class AndroidWearCommandEnvironment @Inject constructor( + private val measurementPermissions: WearMeasurementPermissionUseCase, + private val preferencesRepository: AppPreferencesRepository, + private val getWearSensors: GetWearSensorsUseCase, +) : WearCommandEnvironment { + override suspend fun prepare(request: WearRecordingRequest): AppResult { + val availableSensorIds = getWearSensors().map { it.type }.toSet() + if (!availableSensorIds.containsAll(request.sensorIds)) { + return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear recording sensors")) + } + val includesHeartRate = Sensor.TYPE_HEART_RATE in request.sensorIds + val missingPermissions = measurementPermissions(request.includesGps, includesHeartRate) + if (missingPermissions.isNotEmpty()) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Prepare Wear recording permissions")) + } + return preferencesRepository.preferences.first() + } + + override fun sensors(): List = getWearSensors().map { sensor -> + WearSensorInfo(sensor.type, sensor.name, sensor.vendor, sensor.isHeartRate) + } +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt new file mode 100644 index 0000000..1f93650 --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt @@ -0,0 +1,206 @@ +package com.motionapps.sensorbox.communication + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.domain.measurement.WearRecordingController +import com.motionapps.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY +import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearRecordingRequest +import com.motionapps.wearoslib.protocol.WearSessionCommand +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +class WearCommandHandler @Inject constructor( + private val recordingController: WearRecordingController, + private val environment: WearCommandEnvironment, + private val sendCommand: SendWearCommandUseCase, + private val acknowledgementInbox: WearAcknowledgementInbox, +) { + private val mutex = Mutex() + private val acknowledgements = mutableMapOf() + private var preparedSession: PreparedSession? = null + private var activeSessionId: String? = null + + suspend fun handle(command: WearCommand): AppResult = when (command) { + is WearCommand.PrepareRecording -> sendAcknowledgement(prepare(command)) + + is WearCommand.CommitRecording -> sendAcknowledgement(commit(command)) + + is WearCommand.AbortRecording -> sendAcknowledgement(abort(command)) + + is WearCommand.StopRecording -> sendAcknowledgement(stop(command)) + + WearCommand.RequestSensorList -> sendSensorList() + + is WearCommand.Acknowledgement -> { + acknowledgementInbox.publish(command) + AppResult.success(Unit) + } + + WearCommand.LaunchPhone, + WearCommand.SyncMeasurements, + is WearCommand.SensorList, + -> AppResult.success(Unit) + } + + suspend fun onAutomaticStop(reason: com.motionapps.wearoslib.protocol.WearStopReason): AppResult { + val sessionId = mutex.withLock { + activeSessionId?.also(::clearSession) ?: return AppResult.success(Unit) + } + val command = WearCommand.StopRecording(sessionId, reason) + acknowledgementInbox.clear(sessionId, WearSessionCommand.STOP) + repeat(ATTEMPT_COUNT) { + if (sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, command).isSuccess) { + val acknowledgement = withTimeoutOrNull(STOP_TIMEOUT_MILLIS / ATTEMPT_COUNT) { + acknowledgementInbox.await(sessionId, WearSessionCommand.STOP) + } + if (acknowledgement != null) { + return if (acknowledgement.outcome == WearAcknowledgementOutcome.SUCCEEDED) { + AppResult.success(Unit) + } else { + AppResult.failure( + AppError( + acknowledgement.errorCode ?: AppErrorCode.UNKNOWN, + "Propagate automatic Wear stop", + ), + ) + } + } + } + } + return AppResult.failure( + AppError( + code = AppErrorCode.TIMEOUT, + operation = "Propagate automatic Wear stop", + diagnosticMessage = "Phone stop acknowledgement timed out", + context = mapOf("sessionId" to sessionId, "retryCount" to RETRY_COUNT.toString()), + isRetryable = true, + ), + ) + } + + private suspend fun prepare(command: WearCommand.PrepareRecording): WearCommand.Acknowledgement = mutex.withLock { + cached(command.sessionId, WearSessionCommand.PREPARE)?.let { return@withLock it } + val result = validatePrepare(command) + if (result is AppResult.Success) { + preparedSession = PreparedSession(command.sessionId, command.request, result.value) + } + acknowledgement( + sessionId = command.sessionId, + command = WearSessionCommand.PREPARE, + result = result.map { Unit }, + failureOutcome = WearAcknowledgementOutcome.REJECTED, + ) + } + + private suspend fun validatePrepare(command: WearCommand.PrepareRecording): AppResult { + val occupiedSession = activeSessionId ?: preparedSession?.sessionId + if (occupiedSession != null && occupiedSession != command.sessionId) { + return AppResult.failure(AppError(AppErrorCode.CONFLICT, "Prepare Wear recording")) + } + return environment.prepare(command.request) + } + + private suspend fun commit(command: WearCommand.CommitRecording): WearCommand.Acknowledgement = mutex.withLock { + cached(command.sessionId, WearSessionCommand.COMMIT)?.let { return@withLock it } + val prepared = preparedSession + val result = if (activeSessionId == command.sessionId) { + AppResult.success(Unit) + } else if (prepared?.sessionId != command.sessionId) { + AppResult.failure(AppError(AppErrorCode.CONFLICT, "Commit unprepared Wear recording")) + } else { + recordingController.start( + sessionId = command.sessionId, + request = prepared.request, + preferences = prepared.preferences, + startAtEpochMillis = command.startAtEpochMillis, + ).onSuccess { + activeSessionId = command.sessionId + preparedSession = null + } + } + acknowledgement(command.sessionId, WearSessionCommand.COMMIT, result) + } + + private suspend fun abort(command: WearCommand.AbortRecording): WearCommand.Acknowledgement = mutex.withLock { + cached(command.sessionId, WearSessionCommand.ABORT)?.let { return@withLock it } + val result = when { + activeSessionId == command.sessionId -> recordingController.stop( + command.sessionId, + com.motionapps.wearoslib.protocol.WearStopReason.PAIRED_ABORT, + ) + + preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit) + + else -> AppResult.success(Unit) + } + if (result.isSuccess) clearSession(command.sessionId) + acknowledgement(command.sessionId, WearSessionCommand.ABORT, result) + } + + private suspend fun stop(command: WearCommand.StopRecording): WearCommand.Acknowledgement = mutex.withLock { + cached(command.sessionId, WearSessionCommand.STOP)?.let { return@withLock it } + val result = when { + activeSessionId == command.sessionId -> recordingController.stop(command.sessionId, command.reason) + preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit) + else -> AppResult.success(Unit) + } + if (result.isSuccess) clearSession(command.sessionId) + acknowledgement(command.sessionId, WearSessionCommand.STOP, result) + } + + private fun acknowledgement( + sessionId: String, + command: WearSessionCommand, + result: AppResult, + failureOutcome: WearAcknowledgementOutcome = WearAcknowledgementOutcome.FAILED, + ): WearCommand.Acknowledgement { + val acknowledgement = WearCommand.Acknowledgement( + sessionId = sessionId, + command = command, + outcome = if (result.isSuccess) WearAcknowledgementOutcome.SUCCEEDED else failureOutcome, + errorCode = result.errorOrNull()?.code, + failureCount = if (result.isFailure) 1 else 0, + ) + acknowledgements[AcknowledgementKey(sessionId, command)] = acknowledgement + return acknowledgement + } + + private fun cached(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement? = + acknowledgements[AcknowledgementKey(sessionId, command)] + + private fun clearSession(sessionId: String) { + if (preparedSession?.sessionId == sessionId) preparedSession = null + if (activeSessionId == sessionId) activeSessionId = null + } + + private suspend fun sendSensorList(): AppResult = sendCommand( + PHONE_APP_CAPABILITY, + PHONE_MESSAGE_PATH, + WearCommand.SensorList(environment.sensors()), + ) + + private suspend fun sendAcknowledgement(acknowledgement: WearCommand.Acknowledgement): AppResult = + sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, acknowledgement) + + private data class PreparedSession( + val sessionId: String, + val request: WearRecordingRequest, + val preferences: AppPreferences, + ) + + private data class AcknowledgementKey(val sessionId: String, val command: WearSessionCommand) + + private companion object { + const val STOP_TIMEOUT_MILLIS = 5_000L + const val RETRY_COUNT = 2 + const val ATTEMPT_COUNT = RETRY_COUNT + 1 + } +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt new file mode 100644 index 0000000..48c94c4 --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.communication + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.WearCommandCodec +import javax.inject.Inject + +class WearMessageDispatcher @Inject constructor( + private val commandHandler: WearCommandHandler, + private val diagnosticLogger: DiagnosticLogger, +) { + suspend fun dispatch(path: String, payload: ByteArray): AppResult { + if (path != WEAR_MESSAGE_PATH) return AppResult.success(Unit) + return WearCommandCodec.decode(payload) + .suspendFlatMap(commandHandler::handle) + .onFailure { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } + } +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt new file mode 100644 index 0000000..51d25fa --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt @@ -0,0 +1,51 @@ +package com.motionapps.sensorbox.communication + +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.sensorservices.session.MeasurementSessionEvent +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.sensorservices.session.MeasurementStopReason +import com.motionapps.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearRecordingSessionObserver @Inject constructor( + private val sessionStore: MeasurementSessionStore, + private val commandHandler: WearCommandHandler, + private val diagnosticLogger: DiagnosticLogger, +) { + private val started = AtomicBoolean(false) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun start() { + if (!started.compareAndSet(false, true)) return + scope.launch { + sessionStore.events.collect { event -> + if (event is MeasurementSessionEvent.Stopped) onStopped(event) + } + } + } + + private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) { + event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } + val reason = event.reason.toAutomaticWearReason() ?: return + commandHandler.onAutomaticStop(reason).onFailure { error -> + diagnosticLogger.record(error.toDiagnosticEvent()) + } + } + + private fun MeasurementStopReason.toAutomaticWearReason(): WearStopReason? = when (this) { + MeasurementStopReason.USER_REQUEST -> null + MeasurementStopReason.DURATION_EXPIRED -> WearStopReason.DURATION_EXPIRED + MeasurementStopReason.LOW_BATTERY -> WearStopReason.LOW_BATTERY + MeasurementStopReason.SOURCE_FAILURE -> WearStopReason.SOURCE_FAILURE + MeasurementStopReason.SERVICE_DESTROYED -> WearStopReason.SERVICE_DESTROYED + } +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt new file mode 100644 index 0000000..5021b69 --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt @@ -0,0 +1,47 @@ +package com.motionapps.sensorbox.di + +import android.app.Application +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.error.DiagnosticMetadata +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.error.FileDiagnostics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DiagnosticsModule { + @Provides + @Singleton + fun provideFileDiagnostics(@ApplicationContext context: Context): FileDiagnostics = FileDiagnostics( + context = context, + metadata = DiagnosticMetadata( + appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty(), + buildType = if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + "debug" + } else { + "release" + }, + deviceModel = Build.MODEL, + androidVersion = Build.VERSION.RELEASE, + processName = if (Build.VERSION.SDK_INT >= 28) { + Application.getProcessName() + } else { + context.applicationInfo.processName + }, + ), + ) + + @Provides + fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics + + @Provides + fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt b/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt new file mode 100644 index 0000000..6cdf4f7 --- /dev/null +++ b/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt @@ -0,0 +1,20 @@ +package com.motionapps.sensorbox.di + +import com.motionapps.sensorbox.communication.AndroidWearCommandEnvironment +import com.motionapps.sensorbox.communication.WearCommandEnvironment +import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase +import com.motionapps.sensorbox.domain.measurement.WearRecordingController +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class RecordingModule { + @Binds + abstract fun bindWearRecordingController(implementation: WearMeasurementControlUseCase): WearRecordingController + + @Binds + abstract fun bindWearCommandEnvironment(implementation: AndroidWearCommandEnvironment): WearCommandEnvironment +} diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt index 5dd5e37..ba05083 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt @@ -4,29 +4,61 @@ import android.content.Context import android.content.Intent import android.hardware.SensorManager import androidx.core.content.ContextCompat -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.preferences.AppPreferences import com.motionapps.sensorservices.intent.MeasurementIntentFactory import com.motionapps.sensorservices.intent.MeasurementLaunchRequest import com.motionapps.sensorservices.services.MeasurementService +import com.motionapps.wearoslib.protocol.WearRecordingRequest +import com.motionapps.wearoslib.protocol.WearStopReason import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +interface WearRecordingController { + fun start( + sessionId: String, + request: WearRecordingRequest, + preferences: AppPreferences, + startAtEpochMillis: Long, + ): AppResult + + fun stop(sessionId: String, reason: WearStopReason): AppResult +} + class WearMeasurementControlUseCase @Inject constructor( @ApplicationContext private val context: Context, private val intentFactory: MeasurementIntentFactory, -) { +) : WearRecordingController { + override fun start( + sessionId: String, + request: WearRecordingRequest, + preferences: AppPreferences, + startAtEpochMillis: Long, + ): AppResult = start( + sessionId = sessionId, + sensorIds = request.sensorIds.toSet(), + includesGps = request.includesGps, + preferences = preferences, + folderName = request.folderName, + startAtEpochMillis = startAtEpochMillis, + durationMillis = request.durationMillis, + measurementType = request.measurementType, + ) + fun start( sensorIds: Set, includesGps: Boolean, preferences: AppPreferences, + sessionId: String = java.util.UUID.randomUUID().toString(), folderName: String = intentFactory.newFolderName(), startAtEpochMillis: Long = System.currentTimeMillis(), durationMillis: Long = 0L, measurementType: String = "ENDLESS", - ): Result = appResult(AppError.Kind.MEASUREMENT, "Request Wear measurement start") { + ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement start") { val request = MeasurementLaunchRequest( + sessionId = sessionId, folderName = folderName, useInternalStorage = true, sensorIds = sensorIds, @@ -43,12 +75,14 @@ class WearMeasurementControlUseCase @Inject constructor( ContextCompat.startForegroundService(context, intentFactory.create(request)) } - fun stop(): Result = appResult(AppError.Kind.MEASUREMENT, "Request Wear measurement stop") { + fun stop(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement stop") { val intent = Intent(context, MeasurementService::class.java) .setAction(MeasurementService.ACTION_STOP) context.startService(intent) } + override fun stop(sessionId: String, reason: WearStopReason): AppResult = stop() + private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) { SensorManager.SENSOR_DELAY_FASTEST } diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt index a00c398..489bb1c 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt @@ -2,6 +2,8 @@ package com.motionapps.sensorbox.domain.sync import android.content.Context import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.suspendAppResult import com.motionapps.sensorbox.core.error.suspendFlatMap @@ -19,25 +21,25 @@ class SyncWearMeasurementsUseCase @Inject constructor( private val connectionRepository: WearConnectionRepository, private val transferClient: WearFileTransferClient, ) { - suspend operator fun invoke(): Result = suspendAppResult(AppError.Kind.CONNECTIVITY, "Find phone") { + suspend operator fun invoke(): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") { connectionRepository.findNode(PHONE_APP_CAPABILITY) }.suspendFlatMap { node -> if (node == null) { - return@suspendFlatMap Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Find connected phone")) + return@suspendFlatMap AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find connected phone")) } - appResult(AppError.Kind.STORAGE, "List Wear measurements", ::measurementFiles).suspendFlatMap { files -> - var transferResult: Result = Result.success(Unit) + appResult(AppErrorCode.STORAGE, "List Wear 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(AppError.Kind.CONNECTIVITY, "Sync Wear measurements") + }.withAppError(AppErrorCode.CONNECTIVITY, "Sync Wear measurements") - private suspend fun sendFile(nodeId: String, file: File): Result { + private suspend fun sendFile(nodeId: String, file: File): AppResult { val measurementName = file.parentFile?.name - ?: return Result.failure(AppError(AppError.Kind.STORAGE, "Read Wear measurement folder")) + ?: return AppResult.failure(AppError(AppErrorCode.STORAGE, "Read Wear measurement folder")) return transferClient.send( nodeId = nodeId, metadata = WearFileMetadata(measurementName, file.name), diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt index 88d93cb..a7198ec 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt @@ -3,6 +3,8 @@ package com.motionapps.sensorbox.presentation.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase @@ -112,7 +114,7 @@ class WearDashboardViewModel @Inject constructor( sensorJob = viewModelScope.launch { observeSensorValues(sensorType) .catch { error -> - AppError.from(AppError.Kind.MEASUREMENT, "Observe live sensor", error) + AppError.from(AppErrorCode.MEASUREMENT, "Observe live sensor", error) showSensorError() } .collect(::publishSensorValue) @@ -188,7 +190,7 @@ class WearDashboardViewModel @Inject constructor( private var sampleBuffer: List = emptyList() - private fun Result<*>.showFailure() { + private fun AppResult<*>.showFailure() { if (isFailure) showOperationFailure() } diff --git a/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt b/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt index 5774387..34a824a 100644 --- a/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt +++ b/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt @@ -1,9 +1,9 @@ package com.motionapps.sensorbox -import java.io.File -import javax.xml.parsers.DocumentBuilderFactory import org.junit.Assert.assertEquals import org.junit.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory class PolicyLinksTest { @Test diff --git a/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt b/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt new file mode 100644 index 0000000..89ba6da --- /dev/null +++ b/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt @@ -0,0 +1,133 @@ +package com.motionapps.sensorbox.communication + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.domain.measurement.WearRecordingController +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import com.motionapps.wearoslib.connectivity.WearConnection +import com.motionapps.wearoslib.connectivity.WearConnectionRepository +import com.motionapps.wearoslib.connectivity.WearNode +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import com.motionapps.wearoslib.protocol.WearRecordingRequest +import com.motionapps.wearoslib.protocol.WearSensorInfo +import com.motionapps.wearoslib.protocol.WearStopReason +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 WearCommandHandlerTest { + @Test + fun `Given duplicate session commands When handled Then side effects run once and acknowledgements repeat`() = + runTest { + val fixture = Fixture() + val prepare = WearCommand.PrepareRecording("session-123", request()) + val commit = WearCommand.CommitRecording("session-123", 1_800_000_000_000L) + val stop = WearCommand.StopRecording("session-123", WearStopReason.USER_REQUEST) + + fixture.handler.handle(prepare) + fixture.handler.handle(prepare) + fixture.handler.handle(commit) + fixture.handler.handle(commit) + fixture.handler.handle(stop) + fixture.handler.handle(stop) + + assertEquals(1, fixture.environment.prepareCalls) + assertEquals(1, fixture.controller.startCalls) + assertEquals(1, fixture.controller.stopCalls) + assertEquals(fixture.repository.commands[0], fixture.repository.commands[1]) + assertEquals(fixture.repository.commands[2], fixture.repository.commands[3]) + assertEquals(fixture.repository.commands[4], fixture.repository.commands[5]) + } + + @Test + fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest { + val fixture = Fixture() + val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { }) + val payload = WearCommandCodec.encode(WearCommand.PrepareRecording("session-123", request())).getOrThrow() + + val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, payload) + + assertTrue(result.isSuccess) + assertEquals(1, fixture.environment.prepareCalls) + } + + @Test + fun `Given an old protocol payload When dispatched Then it is rejected before policy runs`() = runTest { + val fixture = Fixture() + val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { }) + + val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1)) + + assertTrue(result.isFailure) + assertEquals(0, fixture.environment.prepareCalls) + } + + private fun request() = WearRecordingRequest( + folderName = "fixture", + sensorIds = listOf(1), + includesGps = false, + ) + + private class Fixture { + val repository = CapturingRepository() + val controller = FakeWearRecordingController() + val environment = FakeWearCommandEnvironment() + val handler = WearCommandHandler( + recordingController = controller, + environment = environment, + sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + acknowledgementInbox = WearAcknowledgementInbox(), + ) + } +} + +private class FakeWearRecordingController : WearRecordingController { + var startCalls = 0 + var stopCalls = 0 + + override fun start( + sessionId: String, + request: WearRecordingRequest, + preferences: AppPreferences, + startAtEpochMillis: Long, + ): AppResult { + startCalls += 1 + return AppResult.success(Unit) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + stopCalls += 1 + return AppResult.success(Unit) + } +} + +private class FakeWearCommandEnvironment : WearCommandEnvironment { + var prepareCalls = 0 + + override suspend fun prepare(request: WearRecordingRequest): AppResult { + prepareCalls += 1 + return AppResult.success(AppPreferences()) + } + + override fun sensors(): List = emptyList() +} + +private class CapturingRepository : WearConnectionRepository { + val commands = mutableListOf() + + override fun observeCapability(capability: String): Flow = emptyFlow() + + override suspend fun findNode(capability: String): WearNode? = WearNode("phone", "Phone", isNearby = true) + + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + commands += WearCommandCodec.decode(payload).getOrThrow() + return AppResult.success(Unit) + } +}