From 16ef61525193181abffaea1887ce1897190fe18c Mon Sep 17 00:00:00 2001 From: Foxpace Date: Wed, 19 Aug 2026 23:48:16 +0200 Subject: [PATCH] refactor(app): own paired recording workflow --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 2 +- .../com/motionapps/sensorbox/MsgListener.kt | 29 +- .../com/motionapps/sensorbox/SensorBoxApp.kt | 13 +- .../sensorbox/di/CoroutineModule.kt | 21 ++ .../sensorbox/di/DiagnosticsModule.kt | 47 ++++ .../sensorbox/di/RecordingModule.kt | 34 +++ .../measurement/DocumentStorageUseCase.kt | 18 +- .../measurement/MeasurementControlUseCase.kt | 114 +------- .../measurement/PhoneRecordingController.kt | 132 +++++++++ .../measurement/RecordingWorkflowGateway.kt | 53 ++++ .../paired/PairedRecordingCoordinator.kt | 265 ++++++++++++++++++ .../paired/PhoneRecordingSessionObserver.kt | 49 ++++ .../domain/paired/PhoneWearCommandHandler.kt | 77 +++++ .../paired/PhoneWearMessageDispatcher.kt | 21 ++ .../domain/paired/WearAcknowledgementInbox.kt | 38 +++ .../domain/sensors/WearSensorCatalogStore.kt | 22 ++ .../domain/sync/ReceiveWearFileUseCase.kt | 10 +- .../domain/sync/WearFileDestination.kt | 44 +-- 19 files changed, 833 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/com/motionapps/sensorbox/di/CoroutineModule.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/di/RecordingModule.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/measurement/RecordingWorkflowGateway.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearCommandHandler.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcher.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/paired/WearAcknowledgementInbox.kt create mode 100644 app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt 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/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/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/app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt new file mode 100644 index 0000000..97ea697 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/domain/sensors/WearSensorCatalogStore.kt @@ -0,0 +1,22 @@ +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 +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WearSensorCatalogStore @Inject constructor() { + private val mutableSensors = MutableStateFlow>(emptyList()) + val sensors: StateFlow> = mutableSensors.asStateFlow() + + fun update(sensors: List) { + mutableSensors.value = sensors.distinctBy(WearSensorInfo::type).sortedBy(WearSensorInfo::name) + } + + fun clear() { + mutableSensors.value = emptyList() + } +} 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, )