diff --git a/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt b/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt
index 5f6331c..9b6648c 100644
--- a/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt
+++ b/wear/src/androidTest/java/com/motionapps/sensorbox/emulator/WearToPhoneSyncSenderEmulatorTest.kt
@@ -3,6 +3,8 @@ package com.motionapps.sensorbox.emulator
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.domain.sync.SyncWearMeasurementsUseCase
import com.motionapps.wearoslib.connectivity.GooglePlayWearConnectionRepository
import com.motionapps.wearoslib.files.GooglePlayWearFileTransferClient
@@ -41,12 +43,16 @@ class WearToPhoneSyncSenderEmulatorTest {
}
private suspend fun syncWhenPhoneBecomesReachable(sync: SyncWearMeasurementsUseCase): Int {
- var lastFailure: Throwable? = null
+ var lastFailure: AppError? = null
repeat(MAX_ATTEMPTS) {
- sync().onSuccess { return it }.onFailure { lastFailure = it }
+ when (val result = sync()) {
+ is AppResult.Success -> return result.value
+ is AppResult.Failure -> lastFailure = result.error
+ }
delay(POLL_INTERVAL_MILLIS)
}
- throw AssertionError("Phone emulator did not become reachable", lastFailure)
+ val message = "Phone emulator did not become reachable: ${lastFailure?.code}"
+ throw AssertionError(message, lastFailure?.cause)
}
private companion object {
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 6f0248d..d598f3a 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -68,7 +68,7 @@
diff --git a/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt b/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt
index b432411..138bf5e 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/WearSensorBoxApp.kt
@@ -1,13 +1,22 @@
package com.motionapps.sensorbox
import android.app.Application
-import com.motionapps.sensorbox.core.error.AppDiagnostics
+import com.motionapps.sensorbox.communication.WearRecordingSessionObserver
+import com.motionapps.sensorbox.core.error.FileDiagnostics
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
@HiltAndroidApp
class WearSensorBoxApp : Application() {
+ @Inject
+ lateinit var diagnostics: FileDiagnostics
+
+ @Inject
+ lateinit var recordingSessionObserver: WearRecordingSessionObserver
+
override fun onCreate() {
super.onCreate()
- AppDiagnostics.install(this)
+ diagnostics.installUncaughtExceptionHandler()
+ recordingSessionObserver.start()
}
}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt
index 122fa1b..6e96a73 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt
@@ -16,7 +16,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.wear.remote.interactions.RemoteActivityHelper
import com.motionapps.sensorbox.R
-import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
import com.motionapps.sensorbox.core.error.appResult
import com.motionapps.sensorbox.presentation.dashboard.WearDashboardEffect
import com.motionapps.sensorbox.presentation.dashboard.WearDashboardIntent
@@ -66,14 +66,14 @@ class MainActivity : ComponentActivity() {
private fun handleEffect(effect: WearDashboardEffect) {
when (effect) {
is WearDashboardEffect.RequestPermissions -> appResult(
- AppError.Kind.PERMISSION,
+ AppErrorCode.PERMISSION,
"Request Wear permissions",
) {
permissions.launch(effect.permissions.toTypedArray())
}
WearDashboardEffect.OpenPhone -> appResult(
- AppError.Kind.EXTERNAL_ACTION,
+ AppErrorCode.EXTERNAL_ACTION,
"Open phone launcher",
) { startActivity(Intent(this, MoveToMain::class.java)) }
@@ -89,12 +89,12 @@ class MainActivity : ComponentActivity() {
val intent = Intent(Intent.ACTION_VIEW)
.addCategory(Intent.CATEGORY_BROWSABLE)
.setData(url.toUri())
- appResult(AppError.Kind.EXTERNAL_ACTION, "Request phone browser") {
+ appResult(AppErrorCode.EXTERNAL_ACTION, "Request phone browser") {
RemoteActivityHelper(this).startRemoteActivity(intent)
}.onSuccess { request ->
request.addListener(
{
- appResult(AppError.Kind.EXTERNAL_ACTION, "Open phone browser") { request.get() }
+ appResult(AppErrorCode.EXTERNAL_ACTION, "Open phone browser") { request.get() }
.fold(
onSuccess = {
Toast.makeText(this, R.string.open_phone_browser, Toast.LENGTH_SHORT).show()
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt
index 32d786c..1d8f6c4 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/MsgListener.kt
@@ -1,108 +1,24 @@
package com.motionapps.sensorbox.communication
-import android.hardware.Sensor
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
-import com.motionapps.sensorbox.core.error.AppError
-import com.motionapps.sensorbox.core.error.suspendAppResult
-import com.motionapps.sensorbox.core.error.suspendFlatMap
-import com.motionapps.sensorbox.core.error.withAppError
-import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository
-import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase
-import com.motionapps.sensorbox.domain.measurement.WearMeasurementPermissionUseCase
-import com.motionapps.sensorbox.domain.sensors.GetWearSensorsUseCase
-import com.motionapps.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY
-import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH
-import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH
-import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase
-import com.motionapps.wearoslib.protocol.WearCommand
-import com.motionapps.wearoslib.protocol.WearCommandCodec
-import com.motionapps.wearoslib.protocol.WearSensorInfo
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MsgListener : WearableListenerService() {
@Inject
- lateinit var measurementControl: WearMeasurementControlUseCase
-
- @Inject
- lateinit var measurementPermissions: WearMeasurementPermissionUseCase
-
- @Inject
- lateinit var preferencesRepository: AppPreferencesRepository
-
- @Inject
- lateinit var getWearSensors: GetWearSensorsUseCase
-
- @Inject
- lateinit var sendWearMessage: SendWearMessageUseCase
+ lateinit var messageDispatcher: WearMessageDispatcher
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onMessageReceived(messageEvent: MessageEvent) {
- if (messageEvent.path != WEAR_MESSAGE_PATH) return
- val command = WearCommandCodec.decode(messageEvent.data).fold(
- onSuccess = { it },
- onFailure = { return },
- )
- when (command) {
- is WearCommand.StartMeasurement -> startMeasurement(command)
- WearCommand.StopMeasurement -> measurementControl.stop()
- WearCommand.RequestSensorList -> sendSensorList()
- WearCommand.SyncMeasurements, WearCommand.LaunchPhone, is WearCommand.SensorList -> Unit
- }
- }
-
- private fun sendSensorList() {
- serviceScope.launch {
- suspendAppResult(AppError.Kind.MEASUREMENT, "Read Wear sensors") {
- val sensors = getWearSensors().map { sensor ->
- WearSensorInfo(sensor.type, sensor.name, sensor.vendor, sensor.isHeartRate)
- }
- sensors
- }.suspendFlatMap { sensors ->
- WearCommandCodec.encode(WearCommand.SensorList(sensors))
- }.suspendFlatMap { payload ->
- sendWearMessage(
- PHONE_APP_CAPABILITY,
- PHONE_MESSAGE_PATH,
- payload,
- )
- }.withAppError(AppError.Kind.CONNECTIVITY, "Send Wear sensor list")
- }
- }
-
- private fun startMeasurement(command: WearCommand.StartMeasurement) {
- serviceScope.launch {
- suspendAppResult(AppError.Kind.PERMISSION, "Check remote measurement permissions") {
- val includesHeartRate = Sensor.TYPE_HEART_RATE in command.sensorIds
- measurementPermissions(command.includesGps, includesHeartRate)
- }.suspendFlatMap { missingPermissions ->
- if (missingPermissions.isNotEmpty()) {
- return@suspendFlatMap Result.failure(
- AppError(AppError.Kind.PERMISSION, "Start remote Wear measurement"),
- )
- }
- preferencesRepository.preferences.first().suspendFlatMap { preferences ->
- measurementControl.start(
- sensorIds = command.sensorIds.toSet(),
- includesGps = command.includesGps,
- preferences = preferences,
- folderName = command.folderName,
- startAtEpochMillis = command.startAtEpochMillis,
- durationMillis = command.durationMillis,
- measurementType = command.measurementType,
- )
- }
- }.withAppError(AppError.Kind.MEASUREMENT, "Handle remote measurement start")
- }
+ serviceScope.launch { messageDispatcher.dispatch(messageEvent.path, messageEvent.data) }
}
override fun onDestroy() {
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt
new file mode 100644
index 0000000..56a0ae0
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt
@@ -0,0 +1,38 @@
+package com.motionapps.sensorbox.communication
+
+import com.motionapps.wearoslib.protocol.WearCommand
+import com.motionapps.wearoslib.protocol.WearSessionCommand
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WearAcknowledgementInbox @Inject constructor() {
+ private val acknowledgements = ConcurrentHashMap()
+ private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE)
+
+ fun publish(acknowledgement: WearCommand.Acknowledgement) {
+ acknowledgements[acknowledgement.key()] = acknowledgement
+ updates.tryEmit(acknowledgement)
+ }
+
+ fun clear(sessionId: String, command: WearSessionCommand) {
+ acknowledgements.remove(Key(sessionId, command))
+ }
+
+ suspend fun await(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement {
+ val key = Key(sessionId, command)
+ acknowledgements[key]?.let { return it }
+ return updates.first { acknowledgement -> acknowledgement.key() == key }
+ }
+
+ private fun WearCommand.Acknowledgement.key() = Key(sessionId, command)
+
+ private data class Key(val sessionId: String, val command: WearSessionCommand)
+
+ private companion object {
+ const val BUFFER_SIZE = 32
+ }
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt
new file mode 100644
index 0000000..0333b8f
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt
@@ -0,0 +1,43 @@
+package com.motionapps.sensorbox.communication
+
+import android.hardware.Sensor
+import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
+import com.motionapps.sensorbox.core.error.AppResult
+import com.motionapps.sensorbox.core.preferences.AppPreferences
+import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository
+import com.motionapps.sensorbox.domain.measurement.WearMeasurementPermissionUseCase
+import com.motionapps.sensorbox.domain.sensors.GetWearSensorsUseCase
+import com.motionapps.wearoslib.protocol.WearRecordingRequest
+import com.motionapps.wearoslib.protocol.WearSensorInfo
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+interface WearCommandEnvironment {
+ suspend fun prepare(request: WearRecordingRequest): AppResult
+
+ fun sensors(): List
+}
+
+class AndroidWearCommandEnvironment @Inject constructor(
+ private val measurementPermissions: WearMeasurementPermissionUseCase,
+ private val preferencesRepository: AppPreferencesRepository,
+ private val getWearSensors: GetWearSensorsUseCase,
+) : WearCommandEnvironment {
+ override suspend fun prepare(request: WearRecordingRequest): AppResult {
+ val availableSensorIds = getWearSensors().map { it.type }.toSet()
+ if (!availableSensorIds.containsAll(request.sensorIds)) {
+ return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Validate Wear recording sensors"))
+ }
+ val includesHeartRate = Sensor.TYPE_HEART_RATE in request.sensorIds
+ val missingPermissions = measurementPermissions(request.includesGps, includesHeartRate)
+ if (missingPermissions.isNotEmpty()) {
+ return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Prepare Wear recording permissions"))
+ }
+ return preferencesRepository.preferences.first()
+ }
+
+ override fun sensors(): List = getWearSensors().map { sensor ->
+ WearSensorInfo(sensor.type, sensor.name, sensor.vendor, sensor.isHeartRate)
+ }
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt
new file mode 100644
index 0000000..1f93650
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt
@@ -0,0 +1,206 @@
+package com.motionapps.sensorbox.communication
+
+import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
+import com.motionapps.sensorbox.core.error.AppResult
+import com.motionapps.sensorbox.core.preferences.AppPreferences
+import com.motionapps.sensorbox.domain.measurement.WearRecordingController
+import com.motionapps.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY
+import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH
+import com.motionapps.wearoslib.protocol.SendWearCommandUseCase
+import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome
+import com.motionapps.wearoslib.protocol.WearCommand
+import com.motionapps.wearoslib.protocol.WearRecordingRequest
+import com.motionapps.wearoslib.protocol.WearSessionCommand
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
+import javax.inject.Inject
+
+class WearCommandHandler @Inject constructor(
+ private val recordingController: WearRecordingController,
+ private val environment: WearCommandEnvironment,
+ private val sendCommand: SendWearCommandUseCase,
+ private val acknowledgementInbox: WearAcknowledgementInbox,
+) {
+ private val mutex = Mutex()
+ private val acknowledgements = mutableMapOf()
+ private var preparedSession: PreparedSession? = null
+ private var activeSessionId: String? = null
+
+ suspend fun handle(command: WearCommand): AppResult = when (command) {
+ is WearCommand.PrepareRecording -> sendAcknowledgement(prepare(command))
+
+ is WearCommand.CommitRecording -> sendAcknowledgement(commit(command))
+
+ is WearCommand.AbortRecording -> sendAcknowledgement(abort(command))
+
+ is WearCommand.StopRecording -> sendAcknowledgement(stop(command))
+
+ WearCommand.RequestSensorList -> sendSensorList()
+
+ is WearCommand.Acknowledgement -> {
+ acknowledgementInbox.publish(command)
+ AppResult.success(Unit)
+ }
+
+ WearCommand.LaunchPhone,
+ WearCommand.SyncMeasurements,
+ is WearCommand.SensorList,
+ -> AppResult.success(Unit)
+ }
+
+ suspend fun onAutomaticStop(reason: com.motionapps.wearoslib.protocol.WearStopReason): AppResult {
+ val sessionId = mutex.withLock {
+ activeSessionId?.also(::clearSession) ?: return AppResult.success(Unit)
+ }
+ val command = WearCommand.StopRecording(sessionId, reason)
+ acknowledgementInbox.clear(sessionId, WearSessionCommand.STOP)
+ repeat(ATTEMPT_COUNT) {
+ if (sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, command).isSuccess) {
+ val acknowledgement = withTimeoutOrNull(STOP_TIMEOUT_MILLIS / ATTEMPT_COUNT) {
+ acknowledgementInbox.await(sessionId, WearSessionCommand.STOP)
+ }
+ if (acknowledgement != null) {
+ return if (acknowledgement.outcome == WearAcknowledgementOutcome.SUCCEEDED) {
+ AppResult.success(Unit)
+ } else {
+ AppResult.failure(
+ AppError(
+ acknowledgement.errorCode ?: AppErrorCode.UNKNOWN,
+ "Propagate automatic Wear stop",
+ ),
+ )
+ }
+ }
+ }
+ }
+ return AppResult.failure(
+ AppError(
+ code = AppErrorCode.TIMEOUT,
+ operation = "Propagate automatic Wear stop",
+ diagnosticMessage = "Phone stop acknowledgement timed out",
+ context = mapOf("sessionId" to sessionId, "retryCount" to RETRY_COUNT.toString()),
+ isRetryable = true,
+ ),
+ )
+ }
+
+ private suspend fun prepare(command: WearCommand.PrepareRecording): WearCommand.Acknowledgement = mutex.withLock {
+ cached(command.sessionId, WearSessionCommand.PREPARE)?.let { return@withLock it }
+ val result = validatePrepare(command)
+ if (result is AppResult.Success) {
+ preparedSession = PreparedSession(command.sessionId, command.request, result.value)
+ }
+ acknowledgement(
+ sessionId = command.sessionId,
+ command = WearSessionCommand.PREPARE,
+ result = result.map { Unit },
+ failureOutcome = WearAcknowledgementOutcome.REJECTED,
+ )
+ }
+
+ private suspend fun validatePrepare(command: WearCommand.PrepareRecording): AppResult {
+ val occupiedSession = activeSessionId ?: preparedSession?.sessionId
+ if (occupiedSession != null && occupiedSession != command.sessionId) {
+ return AppResult.failure(AppError(AppErrorCode.CONFLICT, "Prepare Wear recording"))
+ }
+ return environment.prepare(command.request)
+ }
+
+ private suspend fun commit(command: WearCommand.CommitRecording): WearCommand.Acknowledgement = mutex.withLock {
+ cached(command.sessionId, WearSessionCommand.COMMIT)?.let { return@withLock it }
+ val prepared = preparedSession
+ val result = if (activeSessionId == command.sessionId) {
+ AppResult.success(Unit)
+ } else if (prepared?.sessionId != command.sessionId) {
+ AppResult.failure(AppError(AppErrorCode.CONFLICT, "Commit unprepared Wear recording"))
+ } else {
+ recordingController.start(
+ sessionId = command.sessionId,
+ request = prepared.request,
+ preferences = prepared.preferences,
+ startAtEpochMillis = command.startAtEpochMillis,
+ ).onSuccess {
+ activeSessionId = command.sessionId
+ preparedSession = null
+ }
+ }
+ acknowledgement(command.sessionId, WearSessionCommand.COMMIT, result)
+ }
+
+ private suspend fun abort(command: WearCommand.AbortRecording): WearCommand.Acknowledgement = mutex.withLock {
+ cached(command.sessionId, WearSessionCommand.ABORT)?.let { return@withLock it }
+ val result = when {
+ activeSessionId == command.sessionId -> recordingController.stop(
+ command.sessionId,
+ com.motionapps.wearoslib.protocol.WearStopReason.PAIRED_ABORT,
+ )
+
+ preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit)
+
+ else -> AppResult.success(Unit)
+ }
+ if (result.isSuccess) clearSession(command.sessionId)
+ acknowledgement(command.sessionId, WearSessionCommand.ABORT, result)
+ }
+
+ private suspend fun stop(command: WearCommand.StopRecording): WearCommand.Acknowledgement = mutex.withLock {
+ cached(command.sessionId, WearSessionCommand.STOP)?.let { return@withLock it }
+ val result = when {
+ activeSessionId == command.sessionId -> recordingController.stop(command.sessionId, command.reason)
+ preparedSession?.sessionId == command.sessionId -> AppResult.success(Unit)
+ else -> AppResult.success(Unit)
+ }
+ if (result.isSuccess) clearSession(command.sessionId)
+ acknowledgement(command.sessionId, WearSessionCommand.STOP, result)
+ }
+
+ private fun acknowledgement(
+ sessionId: String,
+ command: WearSessionCommand,
+ result: AppResult,
+ failureOutcome: WearAcknowledgementOutcome = WearAcknowledgementOutcome.FAILED,
+ ): WearCommand.Acknowledgement {
+ val acknowledgement = WearCommand.Acknowledgement(
+ sessionId = sessionId,
+ command = command,
+ outcome = if (result.isSuccess) WearAcknowledgementOutcome.SUCCEEDED else failureOutcome,
+ errorCode = result.errorOrNull()?.code,
+ failureCount = if (result.isFailure) 1 else 0,
+ )
+ acknowledgements[AcknowledgementKey(sessionId, command)] = acknowledgement
+ return acknowledgement
+ }
+
+ private fun cached(sessionId: String, command: WearSessionCommand): WearCommand.Acknowledgement? =
+ acknowledgements[AcknowledgementKey(sessionId, command)]
+
+ private fun clearSession(sessionId: String) {
+ if (preparedSession?.sessionId == sessionId) preparedSession = null
+ if (activeSessionId == sessionId) activeSessionId = null
+ }
+
+ private suspend fun sendSensorList(): AppResult = sendCommand(
+ PHONE_APP_CAPABILITY,
+ PHONE_MESSAGE_PATH,
+ WearCommand.SensorList(environment.sensors()),
+ )
+
+ private suspend fun sendAcknowledgement(acknowledgement: WearCommand.Acknowledgement): AppResult =
+ sendCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, acknowledgement)
+
+ private data class PreparedSession(
+ val sessionId: String,
+ val request: WearRecordingRequest,
+ val preferences: AppPreferences,
+ )
+
+ private data class AcknowledgementKey(val sessionId: String, val command: WearSessionCommand)
+
+ private companion object {
+ const val STOP_TIMEOUT_MILLIS = 5_000L
+ const val RETRY_COUNT = 2
+ const val ATTEMPT_COUNT = RETRY_COUNT + 1
+ }
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt
new file mode 100644
index 0000000..48c94c4
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt
@@ -0,0 +1,21 @@
+package com.motionapps.sensorbox.communication
+
+import com.motionapps.sensorbox.core.error.AppResult
+import com.motionapps.sensorbox.core.error.DiagnosticLogger
+import com.motionapps.sensorbox.core.error.suspendFlatMap
+import com.motionapps.sensorbox.core.error.toDiagnosticEvent
+import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH
+import com.motionapps.wearoslib.protocol.WearCommandCodec
+import javax.inject.Inject
+
+class WearMessageDispatcher @Inject constructor(
+ private val commandHandler: WearCommandHandler,
+ private val diagnosticLogger: DiagnosticLogger,
+) {
+ suspend fun dispatch(path: String, payload: ByteArray): AppResult {
+ if (path != WEAR_MESSAGE_PATH) return AppResult.success(Unit)
+ return WearCommandCodec.decode(payload)
+ .suspendFlatMap(commandHandler::handle)
+ .onFailure { error -> diagnosticLogger.record(error.toDiagnosticEvent()) }
+ }
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt b/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt
new file mode 100644
index 0000000..51d25fa
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt
@@ -0,0 +1,51 @@
+package com.motionapps.sensorbox.communication
+
+import com.motionapps.sensorbox.core.error.DiagnosticLogger
+import com.motionapps.sensorbox.core.error.toDiagnosticEvent
+import com.motionapps.sensorservices.session.MeasurementSessionEvent
+import com.motionapps.sensorservices.session.MeasurementSessionStore
+import com.motionapps.sensorservices.session.MeasurementStopReason
+import com.motionapps.wearoslib.protocol.WearStopReason
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WearRecordingSessionObserver @Inject constructor(
+ private val sessionStore: MeasurementSessionStore,
+ private val commandHandler: WearCommandHandler,
+ private val diagnosticLogger: DiagnosticLogger,
+) {
+ private val started = AtomicBoolean(false)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ fun start() {
+ if (!started.compareAndSet(false, true)) return
+ scope.launch {
+ sessionStore.events.collect { event ->
+ if (event is MeasurementSessionEvent.Stopped) onStopped(event)
+ }
+ }
+ }
+
+ private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) {
+ event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) }
+ val reason = event.reason.toAutomaticWearReason() ?: return
+ commandHandler.onAutomaticStop(reason).onFailure { error ->
+ diagnosticLogger.record(error.toDiagnosticEvent())
+ }
+ }
+
+ private fun MeasurementStopReason.toAutomaticWearReason(): WearStopReason? = when (this) {
+ MeasurementStopReason.USER_REQUEST -> null
+ MeasurementStopReason.DURATION_EXPIRED -> WearStopReason.DURATION_EXPIRED
+ MeasurementStopReason.LOW_BATTERY -> WearStopReason.LOW_BATTERY
+ MeasurementStopReason.SOURCE_FAILURE -> WearStopReason.SOURCE_FAILURE
+ MeasurementStopReason.SERVICE_DESTROYED -> WearStopReason.SERVICE_DESTROYED
+ }
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt
new file mode 100644
index 0000000..5021b69
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt
@@ -0,0 +1,47 @@
+package com.motionapps.sensorbox.di
+
+import android.app.Application
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.os.Build
+import com.motionapps.sensorbox.core.error.DiagnosticLogger
+import com.motionapps.sensorbox.core.error.DiagnosticMetadata
+import com.motionapps.sensorbox.core.error.DiagnosticsStore
+import com.motionapps.sensorbox.core.error.FileDiagnostics
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DiagnosticsModule {
+ @Provides
+ @Singleton
+ fun provideFileDiagnostics(@ApplicationContext context: Context): FileDiagnostics = FileDiagnostics(
+ context = context,
+ metadata = DiagnosticMetadata(
+ appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty(),
+ buildType = if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ "debug"
+ } else {
+ "release"
+ },
+ deviceModel = Build.MODEL,
+ androidVersion = Build.VERSION.RELEASE,
+ processName = if (Build.VERSION.SDK_INT >= 28) {
+ Application.getProcessName()
+ } else {
+ context.applicationInfo.processName
+ },
+ ),
+ )
+
+ @Provides
+ fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics
+
+ @Provides
+ fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt b/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt
new file mode 100644
index 0000000..6cdf4f7
--- /dev/null
+++ b/wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt
@@ -0,0 +1,20 @@
+package com.motionapps.sensorbox.di
+
+import com.motionapps.sensorbox.communication.AndroidWearCommandEnvironment
+import com.motionapps.sensorbox.communication.WearCommandEnvironment
+import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase
+import com.motionapps.sensorbox.domain.measurement.WearRecordingController
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RecordingModule {
+ @Binds
+ abstract fun bindWearRecordingController(implementation: WearMeasurementControlUseCase): WearRecordingController
+
+ @Binds
+ abstract fun bindWearCommandEnvironment(implementation: AndroidWearCommandEnvironment): WearCommandEnvironment
+}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt
index 5dd5e37..ba05083 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt
@@ -4,29 +4,61 @@ import android.content.Context
import android.content.Intent
import android.hardware.SensorManager
import androidx.core.content.ContextCompat
-import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
+import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.core.error.appResult
import com.motionapps.sensorbox.core.preferences.AppPreferences
import com.motionapps.sensorservices.intent.MeasurementIntentFactory
import com.motionapps.sensorservices.intent.MeasurementLaunchRequest
import com.motionapps.sensorservices.services.MeasurementService
+import com.motionapps.wearoslib.protocol.WearRecordingRequest
+import com.motionapps.wearoslib.protocol.WearStopReason
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
+interface WearRecordingController {
+ fun start(
+ sessionId: String,
+ request: WearRecordingRequest,
+ preferences: AppPreferences,
+ startAtEpochMillis: Long,
+ ): AppResult
+
+ fun stop(sessionId: String, reason: WearStopReason): AppResult
+}
+
class WearMeasurementControlUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val intentFactory: MeasurementIntentFactory,
-) {
+) : WearRecordingController {
+ override fun start(
+ sessionId: String,
+ request: WearRecordingRequest,
+ preferences: AppPreferences,
+ startAtEpochMillis: Long,
+ ): AppResult = start(
+ sessionId = sessionId,
+ sensorIds = request.sensorIds.toSet(),
+ includesGps = request.includesGps,
+ preferences = preferences,
+ folderName = request.folderName,
+ startAtEpochMillis = startAtEpochMillis,
+ durationMillis = request.durationMillis,
+ measurementType = request.measurementType,
+ )
+
fun start(
sensorIds: Set,
includesGps: Boolean,
preferences: AppPreferences,
+ sessionId: String = java.util.UUID.randomUUID().toString(),
folderName: String = intentFactory.newFolderName(),
startAtEpochMillis: Long = System.currentTimeMillis(),
durationMillis: Long = 0L,
measurementType: String = "ENDLESS",
- ): Result = appResult(AppError.Kind.MEASUREMENT, "Request Wear measurement start") {
+ ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement start") {
val request = MeasurementLaunchRequest(
+ sessionId = sessionId,
folderName = folderName,
useInternalStorage = true,
sensorIds = sensorIds,
@@ -43,12 +75,14 @@ class WearMeasurementControlUseCase @Inject constructor(
ContextCompat.startForegroundService(context, intentFactory.create(request))
}
- fun stop(): Result = appResult(AppError.Kind.MEASUREMENT, "Request Wear measurement stop") {
+ fun stop(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement stop") {
val intent = Intent(context, MeasurementService::class.java)
.setAction(MeasurementService.ACTION_STOP)
context.startService(intent)
}
+ override fun stop(sessionId: String, reason: WearStopReason): AppResult = stop()
+
private fun samplingPeriod(index: Int): Int = SENSOR_PERIODS.getOrElse(index) {
SensorManager.SENSOR_DELAY_FASTEST
}
diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt
index a00c398..489bb1c 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/domain/sync/SyncWearMeasurementsUseCase.kt
@@ -2,6 +2,8 @@ package com.motionapps.sensorbox.domain.sync
import android.content.Context
import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
+import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.core.error.appResult
import com.motionapps.sensorbox.core.error.suspendAppResult
import com.motionapps.sensorbox.core.error.suspendFlatMap
@@ -19,25 +21,25 @@ class SyncWearMeasurementsUseCase @Inject constructor(
private val connectionRepository: WearConnectionRepository,
private val transferClient: WearFileTransferClient,
) {
- suspend operator fun invoke(): Result = suspendAppResult(AppError.Kind.CONNECTIVITY, "Find phone") {
+ suspend operator fun invoke(): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") {
connectionRepository.findNode(PHONE_APP_CAPABILITY)
}.suspendFlatMap { node ->
if (node == null) {
- return@suspendFlatMap Result.failure(AppError(AppError.Kind.CONNECTIVITY, "Find connected phone"))
+ return@suspendFlatMap AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Find connected phone"))
}
- appResult(AppError.Kind.STORAGE, "List Wear measurements", ::measurementFiles).suspendFlatMap { files ->
- var transferResult: Result = Result.success(Unit)
+ appResult(AppErrorCode.STORAGE, "List Wear measurements", ::measurementFiles).suspendFlatMap { files ->
+ var transferResult: AppResult = AppResult.success(Unit)
for (file in files) {
if (transferResult.isFailure) break
transferResult = sendFile(node.id, file)
}
transferResult.map { files.size }
}
- }.withAppError(AppError.Kind.CONNECTIVITY, "Sync Wear measurements")
+ }.withAppError(AppErrorCode.CONNECTIVITY, "Sync Wear measurements")
- private suspend fun sendFile(nodeId: String, file: File): Result {
+ private suspend fun sendFile(nodeId: String, file: File): AppResult {
val measurementName = file.parentFile?.name
- ?: return Result.failure(AppError(AppError.Kind.STORAGE, "Read Wear measurement folder"))
+ ?: return AppResult.failure(AppError(AppErrorCode.STORAGE, "Read Wear measurement folder"))
return transferClient.send(
nodeId = nodeId,
metadata = WearFileMetadata(measurementName, file.name),
diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt
index 88d93cb..a7198ec 100644
--- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt
+++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt
@@ -3,6 +3,8 @@ package com.motionapps.sensorbox.presentation.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.motionapps.sensorbox.core.error.AppError
+import com.motionapps.sensorbox.core.error.AppErrorCode
+import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent
import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository
import com.motionapps.sensorbox.domain.measurement.WearMeasurementControlUseCase
@@ -112,7 +114,7 @@ class WearDashboardViewModel @Inject constructor(
sensorJob = viewModelScope.launch {
observeSensorValues(sensorType)
.catch { error ->
- AppError.from(AppError.Kind.MEASUREMENT, "Observe live sensor", error)
+ AppError.from(AppErrorCode.MEASUREMENT, "Observe live sensor", error)
showSensorError()
}
.collect(::publishSensorValue)
@@ -188,7 +190,7 @@ class WearDashboardViewModel @Inject constructor(
private var sampleBuffer: List = emptyList()
- private fun Result<*>.showFailure() {
+ private fun AppResult<*>.showFailure() {
if (isFailure) showOperationFailure()
}
diff --git a/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt b/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt
index 5774387..34a824a 100644
--- a/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt
+++ b/wear/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt
@@ -1,9 +1,9 @@
package com.motionapps.sensorbox
-import java.io.File
-import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Test
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
class PolicyLinksTest {
@Test
diff --git a/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt b/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt
new file mode 100644
index 0000000..89ba6da
--- /dev/null
+++ b/wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt
@@ -0,0 +1,133 @@
+package com.motionapps.sensorbox.communication
+
+import com.motionapps.sensorbox.core.error.AppResult
+import com.motionapps.sensorbox.core.error.DiagnosticLogger
+import com.motionapps.sensorbox.core.preferences.AppPreferences
+import com.motionapps.sensorbox.domain.measurement.WearRecordingController
+import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH
+import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase
+import com.motionapps.wearoslib.connectivity.WearConnection
+import com.motionapps.wearoslib.connectivity.WearConnectionRepository
+import com.motionapps.wearoslib.connectivity.WearNode
+import com.motionapps.wearoslib.protocol.SendWearCommandUseCase
+import com.motionapps.wearoslib.protocol.WearCommand
+import com.motionapps.wearoslib.protocol.WearCommandCodec
+import com.motionapps.wearoslib.protocol.WearRecordingRequest
+import com.motionapps.wearoslib.protocol.WearSensorInfo
+import com.motionapps.wearoslib.protocol.WearStopReason
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WearCommandHandlerTest {
+ @Test
+ fun `Given duplicate session commands When handled Then side effects run once and acknowledgements repeat`() =
+ runTest {
+ val fixture = Fixture()
+ val prepare = WearCommand.PrepareRecording("session-123", request())
+ val commit = WearCommand.CommitRecording("session-123", 1_800_000_000_000L)
+ val stop = WearCommand.StopRecording("session-123", WearStopReason.USER_REQUEST)
+
+ fixture.handler.handle(prepare)
+ fixture.handler.handle(prepare)
+ fixture.handler.handle(commit)
+ fixture.handler.handle(commit)
+ fixture.handler.handle(stop)
+ fixture.handler.handle(stop)
+
+ assertEquals(1, fixture.environment.prepareCalls)
+ assertEquals(1, fixture.controller.startCalls)
+ assertEquals(1, fixture.controller.stopCalls)
+ assertEquals(fixture.repository.commands[0], fixture.repository.commands[1])
+ assertEquals(fixture.repository.commands[2], fixture.repository.commands[3])
+ assertEquals(fixture.repository.commands[4], fixture.repository.commands[5])
+ }
+
+ @Test
+ fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest {
+ val fixture = Fixture()
+ val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { })
+ val payload = WearCommandCodec.encode(WearCommand.PrepareRecording("session-123", request())).getOrThrow()
+
+ val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, payload)
+
+ assertTrue(result.isSuccess)
+ assertEquals(1, fixture.environment.prepareCalls)
+ }
+
+ @Test
+ fun `Given an old protocol payload When dispatched Then it is rejected before policy runs`() = runTest {
+ val fixture = Fixture()
+ val dispatcher = WearMessageDispatcher(fixture.handler, DiagnosticLogger { })
+
+ val result = dispatcher.dispatch(WEAR_MESSAGE_PATH, byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1))
+
+ assertTrue(result.isFailure)
+ assertEquals(0, fixture.environment.prepareCalls)
+ }
+
+ private fun request() = WearRecordingRequest(
+ folderName = "fixture",
+ sensorIds = listOf(1),
+ includesGps = false,
+ )
+
+ private class Fixture {
+ val repository = CapturingRepository()
+ val controller = FakeWearRecordingController()
+ val environment = FakeWearCommandEnvironment()
+ val handler = WearCommandHandler(
+ recordingController = controller,
+ environment = environment,
+ sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)),
+ acknowledgementInbox = WearAcknowledgementInbox(),
+ )
+ }
+}
+
+private class FakeWearRecordingController : WearRecordingController {
+ var startCalls = 0
+ var stopCalls = 0
+
+ override fun start(
+ sessionId: String,
+ request: WearRecordingRequest,
+ preferences: AppPreferences,
+ startAtEpochMillis: Long,
+ ): AppResult {
+ startCalls += 1
+ return AppResult.success(Unit)
+ }
+
+ override fun stop(sessionId: String, reason: WearStopReason): AppResult {
+ stopCalls += 1
+ return AppResult.success(Unit)
+ }
+}
+
+private class FakeWearCommandEnvironment : WearCommandEnvironment {
+ var prepareCalls = 0
+
+ override suspend fun prepare(request: WearRecordingRequest): AppResult {
+ prepareCalls += 1
+ return AppResult.success(AppPreferences())
+ }
+
+ override fun sensors(): List = emptyList()
+}
+
+private class CapturingRepository : WearConnectionRepository {
+ val commands = mutableListOf()
+
+ override fun observeCapability(capability: String): Flow = emptyFlow()
+
+ override suspend fun findNode(capability: String): WearNode? = WearNode("phone", "Phone", isNearby = true)
+
+ override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult {
+ commands += WearCommandCodec.decode(payload).getOrThrow()
+ return AppResult.success(Unit)
+ }
+}