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,
)