From 4888c848558170013d17ceffaf31bd22493ffe87 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Wed, 19 Aug 2026 23:49:50 +0200 Subject: [PATCH] refactor(wear): add typed command handlers --- .../WearToPhoneSyncSenderEmulatorTest.kt | 12 +- wear/src/main/AndroidManifest.xml | 2 +- .../motionapps/sensorbox/WearSensorBoxApp.kt | 13 +- .../sensorbox/activities/MainActivity.kt | 10 +- .../sensorbox/communication/MsgListener.kt | 88 +------- .../communication/WearAcknowledgementInbox.kt | 38 ++++ .../communication/WearCommandEnvironment.kt | 43 ++++ .../communication/WearCommandHandler.kt | 206 ++++++++++++++++++ .../communication/WearMessageDispatcher.kt | 21 ++ .../WearRecordingSessionObserver.kt | 51 +++++ .../sensorbox/di/DiagnosticsModule.kt | 47 ++++ .../sensorbox/di/RecordingModule.kt | 20 ++ .../WearMeasurementControlUseCase.kt | 42 +++- .../sync/SyncWearMeasurementsUseCase.kt | 16 +- .../dashboard/WearDashboardViewModel.kt | 6 +- .../motionapps/sensorbox/PolicyLinksTest.kt | 4 +- .../communication/WearCommandHandlerTest.kt | 133 +++++++++++ 17 files changed, 640 insertions(+), 112 deletions(-) create mode 100644 wear/src/main/java/com/motionapps/sensorbox/communication/WearAcknowledgementInbox.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandEnvironment.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/communication/WearCommandHandler.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/communication/WearMessageDispatcher.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/communication/WearRecordingSessionObserver.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt create mode 100644 wear/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt create mode 100644 wear/src/test/java/com/motionapps/sensorbox/communication/WearCommandHandlerTest.kt 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) + } +}