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.
-
-[](https://android-arsenal.com/api?level=24)
-[](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
-[](https://opensource.org/licenses/Apache-2.0)
-* [Flipper](https://github.com/baldapps/Flipper) - storage access framework
-[](https://opensource.org/licenses/MIT)
-* [AppIntro](https://github.com/AppIntro/AppIntro) - introduction to the app for the first launch
-[](https://opensource.org/licenses/Apache-2.0)
-* [Material dialogs](https://github.com/afollestad/material-dialogs) - dialogs with material design style
-[](https://opensource.org/licenses/Apache-2.0)
-* [Number picker](https://github.com/ShawnLin013/NumberPicker) - create custom number pickers
-[](https://opensource.org/licenses/MIT)
-* [Android about page](https://github.com/medyo/android-about-page) - easy way to create about page
-[](https://opensource.org/licenses/MIT)
-* [Licenses dialog](https://github.com/PSDev/LicensesDialog) - dialog to aggreate all licences - check out for the full licenses
-[](https://opensource.org/licenses/Apache-2.0)
-* [Toasty](https://github.com/GrenderG/Toasty) - The usual Toast, but with steroids 💪
-[](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 |
+|:---:|:---:|
+|
|
|
+
+| Wear dashboard | Wear live-sensor picker |
+|:---:|:---:|
+|
|
|
+
+### First-run introduction
+
+| Welcome | Local data | Privacy and terms |
+|:---:|:---:|:---:|
+|
|
|
|
+
+| Android lifecycle | Battery optimization | Recording folder |
+|:---:|:---:|:---:|
+|
|
|
|
+
+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