From 962e80bc1a29a104a7624f18baa0c6d19619fa73 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sun, 23 Aug 2026 22:46:50 +0200 Subject: [PATCH] refactor runtime architecture seams --- .../sensorbox/di/DiagnosticsModule.kt | 5 +- .../measurement/DocumentStorageUseCase.kt | 18 +- .../measurement/MeasurementControlUseCase.kt | 4 +- .../measurement/PhoneRecordingController.kt | 8 +- .../paired/PairedRecordingCoordinator.kt | 4 +- .../domain/sync/WearFileDestination.kt | 16 +- .../presentation/main/MainViewModel.kt | 7 +- .../main/MeasurementSetupScreen.kt | 20 +- .../presentation/main/RecordingViewModel.kt | 10 +- .../presentation/main/SensorDetailsScreen.kt | 12 +- .../presentation/main/SensorPreviewScreen.kt | 4 +- .../presentation/main/SettingsScreen.kt | 20 +- .../measurement/DocumentStorageUseCaseTest.kt | 56 +++++ .../paired/PairedRecordingCoordinatorTest.kt | 2 + .../presentation/main/FeatureViewModelTest.kt | 2 +- .../sensorbox/core/time/EpochClock.kt | 9 + core/build.gradle.kts | 2 + .../core/error/CompositeDiagnosticLogger.kt | 36 +++ .../sensorbox/core/error/FileDiagnostics.kt | 121 +++++---- .../core/preferences/AppPreferences.kt | 14 +- .../core/preferences/AppPreferencesReducer.kt | 24 +- .../DataStoreAppPreferencesRepository.kt | 47 ++-- .../core/storage/NativeDocumentStorage.kt | 105 ++++---- .../error/CompositeDiagnosticLoggerTest.kt | 50 ++++ .../core/error/FileDiagnosticsTest.kt | 59 ++++- .../preferences/AppPreferencesReducerTest.kt | 16 +- .../DataStoreAppPreferencesRepositoryTest.kt | 114 +++++++++ .../core/testing/AppPreferencesFixtures.kt | 7 +- .../sensorbox/core/testing/FakeEpochClock.kt | 7 + gradle/libs.versions.toml | 4 + sensorservices/build.gradle.kts | 3 + .../sensorservices/di/StorageModule.kt | 25 ++ .../sensorservices/handlers/StorageHandler.kt | 125 ++++------ .../ActivityRecognitionMeasurement.kt | 233 +++++------------- .../ActivityRecognitionPlatform.kt | 165 +++++++++++++ .../handlers/measurements/ExtraInfoHandler.kt | 136 +++++----- .../handlers/measurements/GPSMeasurement.kt | 43 ++-- .../measurements/SensorMeasurement.kt | 103 ++++---- .../measurements/SignificantMotion.kt | 19 +- .../intent/MeasurementIntentFactory.kt | 13 +- .../intent/MeasurementLaunchRequest.kt | 4 +- .../AndroidRecordingSources.kt | 83 ++++--- .../serviceController/MeasurementConfig.kt | 5 +- .../serviceController/ServiceController.kt | 16 +- .../services/MeasurementService.kt | 25 +- .../sensorservices/types/SensorHolder.kt | 39 ++- .../handlers/StorageHandlerTest.kt | 70 ++++++ .../ActivityRecognitionMeasurementTest.kt | 108 ++++++++ .../MeasurementMetadataSerializationTest.kt | 64 +++++ .../MeasurementPreparationTest.kt | 66 +++++ .../types/SensorHolderErrorTest.kt | 15 +- .../sensorbox/activities/MainActivity.kt | 4 +- .../sensorbox/di/DiagnosticsModule.kt | 5 +- .../WearMeasurementControlUseCase.kt | 14 +- .../dashboard/WearDashboardScreen.kt | 14 +- .../dashboard/WearDashboardViewModel.kt | 6 +- .../dashboard/WearDashboardReducerTest.kt | 2 +- 57 files changed, 1531 insertions(+), 677 deletions(-) create mode 100644 app/src/test/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCaseTest.kt create mode 100644 core-common/src/main/kotlin/com/motionapps/sensorbox/core/time/EpochClock.kt create mode 100644 core/src/main/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLogger.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt create mode 100644 core/src/test/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt create mode 100644 core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeEpochClock.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/di/StorageModule.kt create mode 100644 sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/handlers/StorageHandlerTest.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt create mode 100644 sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementPreparationTest.kt diff --git a/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt b/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt index 5021b69..b37f27a 100644 --- a/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt +++ b/app/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt @@ -4,10 +4,12 @@ import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build +import com.motionapps.sensorbox.core.error.CompositeDiagnosticLogger 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 com.motionapps.sensorbox.core.error.LogcatDiagnosticLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -40,7 +42,8 @@ object DiagnosticsModule { ) @Provides - fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics + fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = + CompositeDiagnosticLogger(diagnostics, LogcatDiagnosticLogger()) @Provides fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics 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 dfb35f7..8149c64 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 @@ -1,10 +1,8 @@ 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 com.motionapps.sensorbox.core.storage.DocumentStorage import javax.inject.Inject interface DocumentStorageGateway { @@ -15,16 +13,10 @@ interface DocumentStorageGateway { fun persist(resultIntent: Intent): AppResult } -class DocumentStorageUseCase @Inject constructor(@ApplicationContext private val context: Context) : - DocumentStorageGateway { - override fun hasStorage(): AppResult = NativeDocumentStorage.hasAppDirectory(context, APP_DIRECTORY) +class DocumentStorageUseCase @Inject constructor(private val storage: DocumentStorage) : DocumentStorageGateway { + override fun hasStorage(): AppResult = storage.hasConfiguredDirectory() - override fun displayPath(): AppResult = NativeDocumentStorage.displayPath(context, APP_DIRECTORY) + override fun displayPath(): AppResult = storage.displayPath() - override fun persist(resultIntent: Intent): AppResult = - NativeDocumentStorage.persistRootAccess(context, resultIntent, APP_DIRECTORY) - - private companion object { - const val APP_DIRECTORY = "SensorBox" - } + override fun persist(resultIntent: Intent): AppResult = storage.persistRootAccess(resultIntent) } 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 c548fb9..3d72329 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,6 +1,7 @@ package com.motionapps.sensorbox.domain.measurement import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorbox.domain.paired.PairedRecordingCoordinator import com.motionapps.wearoslib.protocol.WearStopReason import javax.inject.Inject @@ -8,11 +9,12 @@ import javax.inject.Inject class MeasurementControlUseCase @Inject constructor( private val pairedRecordingCoordinator: PairedRecordingCoordinator, private val localController: PhoneRecordingController, + private val clock: EpochClock, ) { suspend fun start(request: MeasurementRequest): AppResult = pairedRecordingCoordinator.start(request) suspend fun stop(): AppResult = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST) - fun annotate(text: String, timestampMillis: Long = System.currentTimeMillis()): AppResult = + fun annotate(text: String, timestampMillis: Long = clock.nowMillis()): 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 index 74f3d51..d4e4640 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/measurement/PhoneRecordingController.kt @@ -9,7 +9,7 @@ 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.sensorbox.core.storage.DocumentStorage import com.motionapps.sensorservices.intent.MeasurementIntentFactory import com.motionapps.sensorservices.intent.MeasurementLaunchRequest import com.motionapps.sensorservices.services.MeasurementService @@ -38,12 +38,13 @@ interface PhoneRecordingController { class AndroidPhoneRecordingController @Inject constructor( @ApplicationContext private val context: Context, private val intentFactory: MeasurementIntentFactory, + private val documentStorage: DocumentStorage, ) : PhoneRecordingController { private val committedSessions = mutableSetOf() override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = - NativeDocumentStorage - .hasAppDirectory(context, APP_DIRECTORY) + documentStorage + .hasConfiguredDirectory() .flatMap { storageReady -> if (!storageReady) { AppResult.failure(AppError(AppErrorCode.STORAGE, "Prepare phone recording storage")) @@ -121,7 +122,6 @@ class AndroidPhoneRecordingController @Inject constructor( } private companion object { - const val APP_DIRECTORY = "SensorBox" val SENSOR_PERIODS = intArrayOf( SensorManager.SENSOR_DELAY_FASTEST, SensorManager.SENSOR_DELAY_GAME, 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 index 9f3b070..df1bc93 100644 --- a/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt +++ b/app/src/main/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinator.kt @@ -6,6 +6,7 @@ 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.core.time.EpochClock import com.motionapps.sensorbox.domain.measurement.MeasurementRequest import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController import com.motionapps.sensorbox.domain.measurement.PreparedPhoneRecording @@ -40,6 +41,7 @@ class PairedRecordingCoordinator @Inject constructor( private val acknowledgementInbox: WearAcknowledgementInbox, private val sessionIdFactory: RecordingSessionIdFactory, private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, ) { private val mutex = Mutex() private val mutableSession = MutableStateFlow(null) @@ -93,7 +95,7 @@ class PairedRecordingCoordinator @Inject constructor( private suspend fun commitBoth(preparation: PairedPreparation, request: MeasurementRequest): AppResult { val sessionId = preparation.prepared.sessionId - val startAtEpochMillis = System.currentTimeMillis() + request.delaySeconds.coerceAtLeast(0) * 1_000L + + val startAtEpochMillis = clock.nowMillis() + 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) { 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 a375f56..71b273e 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 @@ -7,7 +7,7 @@ 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.sensorbox.core.storage.DocumentStorage import com.motionapps.wearoslib.files.WearFileMetadata import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File @@ -16,7 +16,10 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class WearFileDestination @Inject constructor(@ApplicationContext private val context: Context) { +class WearFileDestination @Inject constructor( + @ApplicationContext private val context: Context, + private val documentStorage: DocumentStorage, +) { fun isReady(): AppResult = hasConfiguredDirectory().map { configured -> configured || isDebugBuild() } fun copy(metadata: WearFileMetadata, input: InputStream): AppResult { @@ -36,10 +39,8 @@ class WearFileDestination @Inject constructor(@ApplicationContext private val co measurementName: String, fileName: String, input: InputStream, - ): AppResult = NativeDocumentStorage.copyToMeasurement( - context = context, + ): AppResult = documentStorage.copyToMeasurement( input = input, - appDirectoryName = APP_DIRECTORY, measurementName = measurementName, fileName = fileName, mimeType = mimeType(fileName), @@ -62,10 +63,7 @@ class WearFileDestination @Inject constructor(@ApplicationContext private val co } } - private fun hasConfiguredDirectory(): AppResult = NativeDocumentStorage.hasAppDirectory( - context, - APP_DIRECTORY, - ) + private fun hasConfiguredDirectory(): AppResult = documentStorage.hasConfiguredDirectory() private fun isDebugBuild(): Boolean = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt index 6decf57..ef16cd2 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt @@ -42,7 +42,10 @@ class MainViewModel @Inject constructor( onSuccess = { preferences -> val route = if (hasChosenInitialRoute) { state.value.route - } else if (preferences.hasCompletedIntro && preferences.hasAcceptedPolicy) { + } else if ( + preferences.onboarding.hasCompletedIntro && + preferences.onboarding.hasAcceptedPolicy + ) { MainRoute.RECORD } else { MainRoute.ONBOARDING @@ -50,7 +53,7 @@ class MainViewModel @Inject constructor( hasChosenInitialRoute = true mutableState.value = state.value.copy( route = route, - keepScreenAwake = preferences.keepPhoneDisplayOn, + keepScreenAwake = preferences.display.keepPhoneDisplayOn, hasLoadedPreferences = true, ) }, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt index eaa6cd3..6bf7078 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt @@ -52,7 +52,7 @@ private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingI item { TimingSetup(state, onIntent) } item { NotesAndAlarmsSetup(state, onIntent) } item { - SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> onIntent(RecordingIntent.SetSamplingPeriod(index)) } } @@ -199,7 +199,7 @@ private fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> U BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_setup_description), - checked = state.preferences.restrictMeasurementOnLowBattery, + checked = state.preferences.recording.restrictMeasurementOnLowBattery, ) { onIntent(RecordingIntent.SetLowBatteryRestriction(it)) } } @@ -208,7 +208,7 @@ private fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_setup_description), - checked = state.preferences.useWakeLock, + checked = state.preferences.recording.useWakeLock, ) { onIntent(RecordingIntent.SetWakeLock(it)) } } @@ -217,7 +217,7 @@ private fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingInte BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_setup_description), - checked = state.preferences.keepPhoneDisplayOn, + checked = state.preferences.display.keepPhoneDisplayOn, ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } } @@ -225,11 +225,11 @@ private fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingInte private fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_interval), - state.preferences.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, pluralStringResource( R.plurals.seconds_count, - state.preferences.gpsIntervalSeconds, - state.preferences.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, ), 1, 3_600, @@ -242,11 +242,11 @@ private fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) private fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_minimum_distance), - state.preferences.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, pluralStringResource( R.plurals.meters_count, - state.preferences.gpsMinDistanceMeters, - state.preferences.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, ), 0, 10_000, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt index c2ac16a..4c5af3c 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt @@ -260,11 +260,11 @@ class RecordingViewModel @Inject constructor( private fun RecordingState.toMeasurementRequest() = MeasurementRequest( sensorIds = selectedSensorIds, includesGps = includesGps, - samplingPeriodIndex = preferences.sensorSamplingPeriod, - stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, - useWakeLock = preferences.useWakeLock, - gpsIntervalSeconds = preferences.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, + samplingPeriodIndex = preferences.recording.sensorSamplingPeriod, + stopOnLowBattery = preferences.recording.restrictMeasurementOnLowBattery, + useWakeLock = preferences.recording.useWakeLock, + gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, wearSensorIds = selectedWearSensorIds, wearIncludesGps = wearIncludesGps, customName = customMeasurementName, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt index dcf9535..6ac1d2f 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -118,8 +118,8 @@ private fun GpsDetails(state: RecordingState) { permissionRevision += 1 } val details = rememberGpsDetails( - intervalSeconds = state.preferences.gpsIntervalSeconds, - minimumDistanceMeters = state.preferences.gpsMinDistanceMeters, + intervalSeconds = state.preferences.recording.gpsIntervalSeconds, + minimumDistanceMeters = state.preferences.recording.gpsMinDistanceMeters, permissionRevision = permissionRevision, ) val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) @@ -158,16 +158,16 @@ private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unava stringResource(R.string.detail_update_interval), pluralStringResource( R.plurals.seconds_count, - state.preferences.gpsIntervalSeconds, - state.preferences.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, ), ) DetailRow( stringResource(R.string.detail_minimum_distance), pluralStringResource( R.plurals.meters_count, - state.preferences.gpsMinDistanceMeters, - state.preferences.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, ), ) } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt index 177c4f9..a55e432 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -318,8 +318,8 @@ private fun GpsPreview(state: RecordingState) { permissionRevision += 1 } val details = rememberGpsDetails( - intervalSeconds = state.preferences.gpsIntervalSeconds, - minimumDistanceMeters = state.preferences.gpsMinDistanceMeters, + intervalSeconds = state.preferences.recording.gpsIntervalSeconds, + minimumDistanceMeters = state.preferences.recording.gpsMinDistanceMeters, permissionRevision = permissionRevision, ) val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt index 5656660..d0bbd5a 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt @@ -64,7 +64,7 @@ fun SettingsScreen( SensorBoxTopAppBar(stringResource(R.string.measurement_settings), onBack) } item { - SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> onIntent(SettingsIntent.SetSamplingPeriod(index)) } } @@ -170,22 +170,22 @@ private fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { NumberPickerSetting( stringResource(R.string.gps_interval), - state.preferences.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, pluralStringResource( R.plurals.seconds_count, - state.preferences.gpsIntervalSeconds, - state.preferences.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, + state.preferences.recording.gpsIntervalSeconds, ), 1, 3_600, ) { onIntent(SettingsIntent.SetGpsInterval(it)) } NumberPickerSetting( stringResource(R.string.gps_minimum_distance), - state.preferences.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, pluralStringResource( R.plurals.meters_count, - state.preferences.gpsMinDistanceMeters, - state.preferences.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, + state.preferences.recording.gpsMinDistanceMeters, ), 0, 10_000, @@ -265,7 +265,7 @@ private fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_settings_description), - checked = state.preferences.restrictMeasurementOnLowBattery, + checked = state.preferences.recording.restrictMeasurementOnLowBattery, ) { onIntent(SettingsIntent.SetLowBatteryRestriction(it)) } } @@ -274,7 +274,7 @@ private fun CpuWakeLockSetting(state: SettingsState, onIntent: (SettingsIntent) BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_settings_description), - checked = state.preferences.useWakeLock, + checked = state.preferences.recording.useWakeLock, ) { onIntent(SettingsIntent.SetWakeLock(it)) } } @@ -283,7 +283,7 @@ private fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_settings_description), - checked = state.preferences.keepPhoneDisplayOn, + checked = state.preferences.display.keepPhoneDisplayOn, ) { onIntent(SettingsIntent.SetKeepScreenAwake(it)) } } diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCaseTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCaseTest.kt new file mode 100644 index 0000000..85da0bc --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/measurement/DocumentStorageUseCaseTest.kt @@ -0,0 +1,56 @@ +package com.motionapps.sensorbox.domain.measurement + +import android.content.Intent +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.storage.DocumentStorage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStream +import java.io.OutputStream + +class DocumentStorageUseCaseTest { + @Test + fun `Given a document storage adapter When storage operations run Then the same interface owns every result`() { + val storage = FakeDocumentStorage() + val useCase = DocumentStorageUseCase(storage) + val intent = Intent() + + assertTrue(useCase.hasStorage().getOrNull() == true) + assertEquals("Selected folder", useCase.displayPath().getOrNull()) + assertTrue(useCase.persist(intent).isSuccess) + assertSame(intent, storage.persistedIntent) + } + + private class FakeDocumentStorage : DocumentStorage { + var persistedIntent: Intent? = null + + override fun persistRootAccess(intent: Intent): AppResult { + persistedIntent = intent + return AppResult.success(Unit) + } + + override fun hasConfiguredDirectory(): AppResult = AppResult.success(true) + + override fun displayPath(): AppResult = AppResult.success("Selected folder") + + override fun createMeasurementDirectory(measurementName: String): AppResult = AppResult.success(Unit) + + override fun openMeasurementFile( + measurementName: String, + mimeType: String, + fileName: String, + replaceExisting: Boolean, + ): AppResult = error("not used") + + override fun deleteMeasurement(measurementName: String): AppResult = AppResult.success(Unit) + + override fun copyToMeasurement( + input: InputStream, + measurementName: String, + fileName: String, + mimeType: String, + ): AppResult = AppResult.success(Unit) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt index f2bd197..1b24087 100644 --- a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt @@ -4,6 +4,7 @@ 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.time.EpochClock import com.motionapps.sensorbox.domain.measurement.MeasurementRequest import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController import com.motionapps.sensorbox.domain.measurement.PreparedPhoneRecording @@ -139,6 +140,7 @@ class PairedRecordingCoordinatorTest { acknowledgementInbox = inbox, sessionIdFactory = RecordingSessionIdFactory(), diagnosticLogger = DiagnosticLogger { }, + clock = EpochClock { 1_000L }, ) fun succeed(sessionId: String, command: WearSessionCommand) { diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt index b0cbf75..b6c363c 100644 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -71,7 +71,7 @@ class FeatureViewModelTest { viewModel.accept(SettingsIntent.SetSamplingPeriod(3)) advanceUntilIdle() - assertEquals(3, viewModel.state.value.preferences.sensorSamplingPeriod) + assertEquals(3, viewModel.state.value.preferences.recording.sensorSamplingPeriod) } @Test diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/time/EpochClock.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/time/EpochClock.kt new file mode 100644 index 0000000..2d0b498 --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/time/EpochClock.kt @@ -0,0 +1,9 @@ +package com.motionapps.sensorbox.core.time + +fun interface EpochClock { + fun nowMillis(): Long +} + +object SystemEpochClock : EpochClock { + override fun nowMillis(): Long = System.currentTimeMillis() +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 74da485..02608fc 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -28,7 +28,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.documentfile) implementation(libs.coroutines.core) + implementation(libs.kotlinx.datetime) testFixturesImplementation(libs.coroutines.core) + testImplementation(libs.coroutines.test) testImplementation(libs.junit) } diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLogger.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLogger.kt new file mode 100644 index 0000000..d1d2a27 --- /dev/null +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLogger.kt @@ -0,0 +1,36 @@ +package com.motionapps.sensorbox.core.error + +import android.util.Log + +class CompositeDiagnosticLogger(private vararg val loggers: DiagnosticLogger) : DiagnosticLogger { + override fun record(event: DiagnosticEvent) { + loggers.forEach { logger -> + runCatching { logger.record(event) } + } + } +} + +internal fun interface LogcatWriter { + fun write(severity: DiagnosticSeverity, tag: String, message: String, cause: Throwable?) +} + +class LogcatDiagnosticLogger internal constructor(private val tag: String, private val writer: LogcatWriter) : + DiagnosticLogger { + constructor(tag: String = "SensorBox") : this(tag, ANDROID_LOGCAT_WRITER) + + override fun record(event: DiagnosticEvent) { + val message = "${event.code} | ${event.operation} | ${event.diagnosticMessage}" + writer.write(event.severity, tag, message, event.cause) + } + + private companion object { + val ANDROID_LOGCAT_WRITER = LogcatWriter { severity, tag, message, cause -> + when (severity) { + DiagnosticSeverity.INFO -> Log.i(tag, message, cause) + DiagnosticSeverity.WARNING -> Log.w(tag, message, cause) + DiagnosticSeverity.ERROR -> Log.e(tag, message, cause) + DiagnosticSeverity.FATAL -> Log.wtf(tag, message, cause) + } + } + } +} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt index fc46960..def301b 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/error/FileDiagnostics.kt @@ -1,6 +1,14 @@ package com.motionapps.sensorbox.core.error import android.content.Context +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorbox.core.time.SystemEpochClock +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -17,63 +25,64 @@ data class DiagnosticMetadata( class FileDiagnostics internal constructor( private val diagnosticsDirectory: File, private val metadata: DiagnosticMetadata, + private val clock: EpochClock = SystemEpochClock, ) : DiagnosticLogger, DiagnosticsStore { - private val lock = Any() + private val fileLock = Any() + private val handlerLock = Any() constructor(context: Context, metadata: DiagnosticMetadata) : this( diagnosticsDirectory = File(context.filesDir, DIRECTORY_NAME), metadata = metadata, ) - @Volatile private var uncaughtHandlerInstalled = false override fun record(event: DiagnosticEvent) { val entry = event.toDiagnosticEntry().take(MAX_ENTRY_CHARS) - synchronized(lock) { + synchronized(fileLock) { appendSafely(entry) } } fun installUncaughtExceptionHandler() { - if (uncaughtHandlerInstalled) return - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, error -> - record( - DiagnosticEvent( - severity = DiagnosticSeverity.FATAL, - code = AppErrorCode.UNKNOWN, - operation = "Uncaught exception", - diagnosticMessage = "Uncaught ${error::class.java.simpleName}", - cause = error, - context = mapOf("threadName" to thread.name), - ), - ) - previous?.uncaughtException(thread, error) + synchronized(handlerLock) { + if (uncaughtHandlerInstalled) return + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, error -> + record( + DiagnosticEvent( + severity = DiagnosticSeverity.FATAL, + code = AppErrorCode.UNKNOWN, + operation = "Uncaught exception", + diagnosticMessage = "Uncaught ${error::class.java.simpleName}", + cause = error, + context = mapOf("threadName" to thread.name), + ), + ) + previous?.uncaughtException(thread, error) + } + uncaughtHandlerInstalled = true } - uncaughtHandlerInstalled = true } override fun readText(): AppResult = appResult(AppErrorCode.STORAGE, "Read diagnostics") { - synchronized(lock) { - val files = listOf(previousFile(), currentFile()).filter(File::exists) - if (files.isEmpty()) NO_DIAGNOSTICS else files.joinToString(separator = "") { it.readText() } + synchronized(fileLock) { + readTextLocked() } } - override fun exportFile(): AppResult = readText().flatMap { text -> - appResult(AppErrorCode.STORAGE, "Export diagnostics") { - synchronized(lock) { - diagnosticsDirectory.mkdirs() - exportFilePath().apply { writeText(text) } - } + override fun exportFile(): AppResult = appResult(AppErrorCode.STORAGE, "Export diagnostics") { + synchronized(fileLock) { + val text = readTextLocked() + diagnosticsDirectory.mkdirs() + exportFilePath().apply { writeText(text) } } } override fun clear(): AppResult = appResult(AppErrorCode.STORAGE, "Clear diagnostics") { - synchronized(lock) { - listOf(currentFile(), previousFile(), exportFilePath()).forEach { file -> + synchronized(fileLock) { + diagnosticFiles().plus(exportFilePath()).forEach { file -> check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } } } @@ -82,22 +91,15 @@ class FileDiagnostics internal constructor( private fun appendSafely(entry: String) { try { diagnosticsDirectory.mkdirs() - val file = currentFile() - if (file.length() + entry.toByteArray().size > MAX_FILE_BYTES) rotate(file) - file.appendText(entry) + pruneStaleFiles() + currentFile().appendText(entry) } catch (_: Throwable) { // A diagnostics write must never become an application failure. } } - private fun rotate(file: File) { - val previous = previousFile() - if (previous.exists()) previous.delete() - if (file.exists()) file.renameTo(previous) - } - private fun DiagnosticEvent.toDiagnosticEntry(): String = buildString { - val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) + val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date(clock.nowMillis())) append(timestamp).append(" | ").append(severity).append(" | ").append(code).append(" | ") .append(operation.safeText()).appendLine() append("message=").append(diagnosticMessage.safeText()).appendLine() @@ -122,16 +124,47 @@ class FileDiagnostics internal constructor( private fun String.safeText(): String = replace('\n', ' ').replace('\r', ' ').take(MAX_FIELD_CHARS) - private fun currentFile(): File = File(diagnosticsDirectory, FILE_NAME) + private fun readTextLocked(): String { + val files = diagnosticFiles().sortedBy(File::getName) + return if (files.isEmpty()) NO_DIAGNOSTICS else files.joinToString(separator = "") { it.readText() } + } + + private fun diagnosticFiles(): List = diagnosticsDirectory.listFiles().orEmpty() + .filter { + it.isFile && + it.name != EXPORT_FILE_NAME && + it.name.startsWith(FILE_PREFIX) && + it.name.endsWith(FILE_SUFFIX) + } + + private fun currentFile(): File = File( + diagnosticsDirectory, + "$FILE_PREFIX${currentDate()}$FILE_SUFFIX", + ) + + private fun pruneStaleFiles() { + val retainedDates = (0 until RETENTION_DAYS).map { days -> + currentDate().minus( + DatePeriod(days = days), + ) + }.toSet() + diagnosticFiles().forEach { file -> + val dateText = file.name.removePrefix(FILE_PREFIX).removeSuffix(FILE_SUFFIX) + val date = runCatching { LocalDate.parse(dateText) }.getOrNull() + if (date == null || date !in retainedDates) file.delete() + } + } - private fun previousFile(): File = File(diagnosticsDirectory, PREVIOUS_FILE_NAME) + private fun currentDate(): LocalDate = Instant.fromEpochMilliseconds(clock.nowMillis()) + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date private fun exportFilePath(): File = File(diagnosticsDirectory, EXPORT_FILE_NAME) private companion object { const val DIRECTORY_NAME = "diagnostics" - const val FILE_NAME = "sensorbox-diagnostics.txt" - const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt" + const val FILE_PREFIX = "sensorbox-diagnostics-" + const val FILE_SUFFIX = ".txt" const val EXPORT_FILE_NAME = "sensorbox-diagnostics-export.txt" const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" const val ENTRY_SEPARATOR = "---" @@ -139,7 +172,7 @@ class FileDiagnostics internal constructor( const val MAX_ENTRY_CHARS = 32_000 const val MAX_FIELD_CHARS = 512 const val MAX_STACK_FRAMES = 80 - const val MAX_FILE_BYTES = 1_000_000L + const val RETENTION_DAYS = 7 val SAFE_CONTEXT_KEYS = setOf( "androidVersion", "buildType", diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt index 773130d..b90dcb1 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferences.kt @@ -1,13 +1,19 @@ package com.motionapps.sensorbox.core.preferences data class AppPreferences( - val hasCompletedIntro: Boolean = false, - val hasAcceptedPolicy: Boolean = false, + val onboarding: OnboardingPreferences = OnboardingPreferences(), + val recording: RecordingPreferences = RecordingPreferences(), + val display: DisplayPreferences = DisplayPreferences(), +) + +data class OnboardingPreferences(val hasCompletedIntro: Boolean = false, val hasAcceptedPolicy: Boolean = false) + +data class RecordingPreferences( val gpsIntervalSeconds: Int = 10, val gpsMinDistanceMeters: Int = 20, val sensorSamplingPeriod: Int = 0, val restrictMeasurementOnLowBattery: Boolean = true, val useWakeLock: Boolean = false, - val keepPhoneDisplayOn: Boolean = false, - val keepWearDisplayOn: Boolean = false, ) + +data class DisplayPreferences(val keepPhoneDisplayOn: Boolean = false, val keepWearDisplayOn: Boolean = false) diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt index 50b0969..9771daa 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducer.kt @@ -2,34 +2,40 @@ package com.motionapps.sensorbox.core.preferences object AppPreferencesReducer { fun reduce(current: AppPreferences, intent: AppPreferencesIntent): AppPreferences = when (intent) { - AppPreferencesIntent.AcceptPolicy -> current.copy(hasAcceptedPolicy = true) + AppPreferencesIntent.AcceptPolicy -> current.copy( + onboarding = current.onboarding.copy(hasAcceptedPolicy = true), + ) - AppPreferencesIntent.CompleteIntro -> current.copy(hasCompletedIntro = true) + AppPreferencesIntent.CompleteIntro -> current.copy( + onboarding = current.onboarding.copy(hasCompletedIntro = true), + ) is AppPreferencesIntent.SetGpsInterval -> current.copy( - gpsIntervalSeconds = intent.seconds.coerceAtLeast(1), + recording = current.recording.copy(gpsIntervalSeconds = intent.seconds.coerceAtLeast(1)), ) is AppPreferencesIntent.SetGpsMinDistance -> current.copy( - gpsMinDistanceMeters = intent.meters.coerceAtLeast(0), + recording = current.recording.copy(gpsMinDistanceMeters = intent.meters.coerceAtLeast(0)), ) is AppPreferencesIntent.SetKeepWearDisplayOn -> current.copy( - keepWearDisplayOn = intent.enabled, + display = current.display.copy(keepWearDisplayOn = intent.enabled), ) is AppPreferencesIntent.SetKeepPhoneDisplayOn -> current.copy( - keepPhoneDisplayOn = intent.enabled, + display = current.display.copy(keepPhoneDisplayOn = intent.enabled), ) is AppPreferencesIntent.SetLowBatteryRestriction -> current.copy( - restrictMeasurementOnLowBattery = intent.enabled, + recording = current.recording.copy(restrictMeasurementOnLowBattery = intent.enabled), ) is AppPreferencesIntent.SetSensorSamplingPeriod -> current.copy( - sensorSamplingPeriod = intent.period, + recording = current.recording.copy(sensorSamplingPeriod = intent.period), ) - is AppPreferencesIntent.SetWakeLock -> current.copy(useWakeLock = intent.enabled) + is AppPreferencesIntent.SetWakeLock -> current.copy( + recording = current.recording.copy(useWakeLock = intent.enabled), + ) } } diff --git a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt index 0814460..81ed46c 100644 --- a/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt +++ b/core/src/main/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepository.kt @@ -1,6 +1,7 @@ package com.motionapps.sensorbox.core.preferences import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit @@ -9,7 +10,6 @@ import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.suspendAppResult -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -18,7 +18,6 @@ class DataStoreAppPreferencesRepository(private val dataStore: DataStore> = dataStore.data .map { values -> AppResult.success(values.toAppPreferences()) } .catch { error -> - if (error is CancellationException) throw error emit(AppResult.failure(AppError.from(AppErrorCode.PREFERENCES, "Read preferences", error))) } @@ -33,27 +32,33 @@ class DataStoreAppPreferencesRepository(private val dataStore: DataStore { +interface DocumentStorage { + fun persistRootAccess(intent: Intent): AppResult + + fun hasConfiguredDirectory(): AppResult + + fun displayPath(): AppResult + + fun createMeasurementDirectory(measurementName: String): AppResult + + fun openMeasurementFile( + measurementName: String, + mimeType: String, + fileName: String, + replaceExisting: Boolean = false, + ): AppResult + + fun deleteMeasurement(measurementName: String): AppResult + + fun copyToMeasurement( + input: InputStream, + measurementName: String, + fileName: String, + mimeType: String, + ): AppResult +} + +class NativeDocumentStorage(private val context: Context) : DocumentStorage { + override fun persistRootAccess(intent: Intent): AppResult { val uri = intent.data ?: return storageFailure("Storage directory was not selected") val grantFlags = intent.flags and READ_WRITE_FLAGS if (grantFlags == 0) return storageFailure("Storage permission was not granted") @@ -36,7 +62,7 @@ object NativeDocumentStorage { return@flatMap storageFailure("Selected storage location is not a directory") } releaseOtherRootPermissions(context, uri) - if (appDirectory(context, appDirectoryName, create = true) == null) { + if (configuredDirectory() == null) { storageFailure("Storage directory is unavailable") } else { AppResult.success(Unit) @@ -44,46 +70,41 @@ object NativeDocumentStorage { } } - fun hasAppDirectory(context: Context, appDirectoryName: String): AppResult = appResult( + override fun hasConfiguredDirectory(): AppResult = appResult( AppErrorCode.STORAGE, "Check storage directory", ) { - appDirectory(context, appDirectoryName, create = false)?.exists() == true + configuredDirectory()?.exists() == true } - fun displayPath(context: Context, appDirectoryName: String): AppResult = appResult( + override fun displayPath(): AppResult = appResult( AppErrorCode.STORAGE, "Read storage path", ) { - val selectedDirectory = appDirectory(context, appDirectoryName, create = false) ?: return@appResult null + val selectedDirectory = configuredDirectory() ?: return@appResult null selectedDirectory.name ?: selectedDirectory.uri.lastPathSegment } - fun createMeasurementDirectory( - context: Context, - appDirectoryName: String, - measurementName: String, - ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement root") { - appDirectory(context, appDirectoryName, create = false) - }.flatMap { appDirectory -> - if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") - appResult(AppErrorCode.STORAGE, "Create measurement directory") { - findDirectory(appDirectory, measurementName) != null || - appDirectory.createDirectory(measurementName) != null - }.flatMap { created -> - if (created) AppResult.success(Unit) else storageFailure("Unable to create measurement directory") + override fun createMeasurementDirectory(measurementName: String): AppResult = + appResult(AppErrorCode.STORAGE, "Access measurement root") { + configuredDirectory() + }.flatMap { appDirectory -> + if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") + appResult(AppErrorCode.STORAGE, "Create measurement directory") { + findDirectory(appDirectory, measurementName) != null || + appDirectory.createDirectory(measurementName) != null + }.flatMap { created -> + if (created) AppResult.success(Unit) else storageFailure("Unable to create measurement directory") + } } - } - fun openMeasurementFile( - context: Context, - appDirectoryName: String, + override fun openMeasurementFile( measurementName: String, mimeType: String, fileName: String, - replaceExisting: Boolean = false, + replaceExisting: Boolean, ): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement directory") { - measurementDirectory(context, appDirectoryName, measurementName) + measurementDirectory(measurementName) }.flatMap { directory -> if (directory == null) return@flatMap storageFailure("Measurement directory is unavailable") createOrReplaceFile(directory, mimeType, fileName, replaceExisting).flatMap { createdFile -> @@ -96,9 +117,9 @@ object NativeDocumentStorage { } } - fun deleteMeasurement(context: Context, appDirectoryName: String, measurementName: String): AppResult = + override fun deleteMeasurement(measurementName: String): AppResult = appResult(AppErrorCode.STORAGE, "Access measurement directory") { - appDirectory(context, appDirectoryName, create = false) + configuredDirectory() }.flatMap { appDirectory -> if (appDirectory == null) return@flatMap storageFailure("Storage directory is not configured") val directory = findDirectory(appDirectory, measurementName) @@ -108,16 +129,12 @@ object NativeDocumentStorage { } } - fun copyToMeasurement( - context: Context, + override fun copyToMeasurement( input: InputStream, - appDirectoryName: String, measurementName: String, fileName: String, mimeType: String, ): AppResult = openMeasurementFile( - context = context, - appDirectoryName = appDirectoryName, measurementName = measurementName, mimeType = mimeType, fileName = fileName, @@ -129,23 +146,15 @@ object NativeDocumentStorage { } } - private fun measurementDirectory( - context: Context, - appDirectoryName: String, - measurementName: String, - ): DocumentFile? { - val appDirectory = appDirectory(context, appDirectoryName, create = false) ?: return null + private fun measurementDirectory(measurementName: String): DocumentFile? { + val appDirectory = configuredDirectory() ?: return null return findDirectory(appDirectory, measurementName) ?: appDirectory.createDirectory(measurementName) } - private fun appDirectory( - context: Context, - @Suppress("UNUSED_PARAMETER") directoryName: String, - @Suppress("UNUSED_PARAMETER") create: Boolean, - ): DocumentFile? = persistedRoot(context)?.takeIf(DocumentFile::isDirectory) + private fun configuredDirectory(): DocumentFile? = persistedRoot()?.takeIf(DocumentFile::isDirectory) - private fun persistedRoot(context: Context): DocumentFile? { + private fun persistedRoot(): DocumentFile? { val permission = context.contentResolver.persistedUriPermissions .filter { it.isReadPermission && it.isWritePermission } .maxByOrNull { it.persistedTime } @@ -156,8 +165,10 @@ object NativeDocumentStorage { private fun findDirectory(parent: DocumentFile, name: String): DocumentFile? = parent.findFile(name)?.takeIf(DocumentFile::isDirectory) - private const val READ_WRITE_FLAGS = - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + private companion object { + const val READ_WRITE_FLAGS = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } } private fun createOrReplaceFile( diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt new file mode 100644 index 0000000..5f57201 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/CompositeDiagnosticLoggerTest.kt @@ -0,0 +1,50 @@ +package com.motionapps.sensorbox.core.error + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CompositeDiagnosticLoggerTest { + @Test + fun `Given one failed logger When an event is recorded Then remaining loggers still receive it`() { + val received = mutableListOf() + val logger = CompositeDiagnosticLogger( + DiagnosticLogger { error("disk failed") }, + DiagnosticLogger(received::add), + ) + + logger.record(event(DiagnosticSeverity.ERROR)) + + assertEquals(listOf(DiagnosticSeverity.ERROR), received.map(DiagnosticEvent::severity)) + } + + @Test + fun `Given every severity When Logcat records Then severity tag message and cause are routed`() { + val writes = mutableListOf() + val cause = IllegalStateException("fixture") + val logger = LogcatDiagnosticLogger("TestTag") { severity, tag, message, error -> + writes += LogcatWrite(severity, tag, message, error) + } + + DiagnosticSeverity.entries.forEach { severity -> logger.record(event(severity, cause)) } + + assertEquals(DiagnosticSeverity.entries, writes.map(LogcatWrite::severity)) + assertEquals(setOf("TestTag"), writes.map(LogcatWrite::tag).toSet()) + assertEquals(setOf(cause), writes.map(LogcatWrite::cause).toSet()) + assertEquals(true, writes.all { "STORAGE | Test operation | Test message" == it.message }) + } + + private fun event(severity: DiagnosticSeverity, cause: Throwable? = null) = DiagnosticEvent( + severity = severity, + code = AppErrorCode.STORAGE, + operation = "Test operation", + diagnosticMessage = "Test message", + cause = cause, + ) + + private data class LogcatWrite( + val severity: DiagnosticSeverity, + val tag: String, + val message: String, + val cause: Throwable?, + ) +} diff --git a/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt index b14462f..7c9c5fb 100644 --- a/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt +++ b/core/src/test/java/com/motionapps/sensorbox/core/error/FileDiagnosticsTest.kt @@ -1,27 +1,35 @@ package com.motionapps.sensorbox.core.error +import com.motionapps.sensorbox.core.time.EpochClock +import kotlinx.datetime.Instant import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread class FileDiagnosticsTest { @get:Rule val temporaryFolder = TemporaryFolder() @Test - fun `Given many events When logs rotate Then only two one megabyte files remain`() { + fun `Given eight calendar days When events are recorded Then only seven dated files remain`() { val directory = temporaryFolder.newFolder("diagnostics") - val diagnostics = diagnostics(directory) + var currentMillis = Instant.parse("2026-08-01T12:00:00Z").toEpochMilliseconds() + val diagnostics = diagnostics(directory, EpochClock { currentMillis }) - repeat(2_500) { index -> diagnostics.record(event(message = "$index ${"x".repeat(1_000)}")) } + repeat(8) { day -> + diagnostics.record(event(message = "day $day")) + currentMillis += 24 * 60 * 60 * 1_000L + } val retainedLogs = directory.listFiles().orEmpty().filter { "export" !in it.name } - assertEquals(2, retainedLogs.size) - assertTrue(retainedLogs.all { it.length() <= 1_000_000L }) - assertTrue(diagnostics.readText().getOrNull().orEmpty().contains("2499 ")) + assertEquals(7, retainedLogs.size) + assertFalse(retainedLogs.any { it.name.contains("2026-08-01") }) + assertTrue(diagnostics.readText().getOrNull().orEmpty().contains("day 7")) } @Test @@ -74,7 +82,43 @@ class FileDiagnosticsTest { assertEquals("No diagnostics have been recorded.\n", diagnostics.readText().getOrNull()) } - private fun diagnostics(directory: java.io.File) = FileDiagnostics( + @Test + fun `Given retained logs When exported Then ordered text is copied to the export file`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + diagnostics.record(event(message = "First")) + diagnostics.record(event(message = "Second")) + + val exported = diagnostics.exportFile().getOrNull() + + assertEquals(diagnostics.readText().getOrNull(), exported?.readText()) + assertTrue(exported?.name?.contains("export") == true) + } + + @Test + fun `Given concurrent installation When an exception is dispatched Then the handler is installed once`() { + val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) + val original = Thread.getDefaultUncaughtExceptionHandler() + val delegated = AtomicInteger() + Thread.setDefaultUncaughtExceptionHandler { _, _ -> delegated.incrementAndGet() } + try { + List(20) { thread(start = true) { diagnostics.installUncaughtExceptionHandler() } } + .forEach(Thread::join) + + Thread.getDefaultUncaughtExceptionHandler() + ?.uncaughtException(Thread.currentThread(), IllegalStateException("fixture")) + + val text = diagnostics.readText().getOrNull().orEmpty() + assertEquals(1, text.split("Uncaught IllegalStateException").size - 1) + assertEquals(1, delegated.get()) + } finally { + Thread.setDefaultUncaughtExceptionHandler(original) + } + } + + private fun diagnostics( + directory: java.io.File, + clock: EpochClock = EpochClock { Instant.parse("2026-08-23T12:00:00Z").toEpochMilliseconds() }, + ) = FileDiagnostics( diagnosticsDirectory = directory, metadata = DiagnosticMetadata( appVersion = "1.0", @@ -83,6 +127,7 @@ class FileDiagnosticsTest { androidVersion = "test android", processName = "test process", ), + clock = clock, ) private fun event(message: String, context: Map = emptyMap()) = DiagnosticEvent( diff --git a/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt index e5d47d2..8c7d37b 100644 --- a/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt +++ b/core/src/test/java/com/motionapps/sensorbox/core/preferences/AppPreferencesReducerTest.kt @@ -15,7 +15,7 @@ class AppPreferencesReducerTest { intent = AppPreferencesIntent.CompleteIntro, ) - assertTrue(actual.hasCompletedIntro) + assertTrue(actual.onboarding.hasCompletedIntro) } @Test @@ -27,30 +27,34 @@ class AppPreferencesReducerTest { intent = AppPreferencesIntent.SetGpsInterval(seconds = 0), ) - assertEquals(1, actual.gpsIntervalSeconds) + assertEquals(1, actual.recording.gpsIntervalSeconds) } @Test fun `Given battery protection enabled When disabled Then the preference changes`() { - val givenPreferences = AppPreferencesFixtures.preferences().copy(restrictMeasurementOnLowBattery = true) + val original = AppPreferencesFixtures.preferences() + val givenPreferences = original.copy( + recording = original.recording.copy(restrictMeasurementOnLowBattery = true), + ) val actual = AppPreferencesReducer.reduce( current = givenPreferences, intent = AppPreferencesIntent.SetLowBatteryRestriction(false), ) - assertEquals(false, actual.restrictMeasurementOnLowBattery) + assertEquals(false, actual.recording.restrictMeasurementOnLowBattery) } @Test fun `Given screen awake disabled When enabled Then the preference changes`() { - val givenPreferences = AppPreferencesFixtures.preferences().copy(keepPhoneDisplayOn = false) + val original = AppPreferencesFixtures.preferences() + val givenPreferences = original.copy(display = original.display.copy(keepPhoneDisplayOn = false)) val actual = AppPreferencesReducer.reduce( current = givenPreferences, intent = AppPreferencesIntent.SetKeepPhoneDisplayOn(true), ) - assertTrue(actual.keepPhoneDisplayOn) + assertTrue(actual.display.keepPhoneDisplayOn) } } diff --git a/core/src/test/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt b/core/src/test/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt new file mode 100644 index 0000000..a83dc77 --- /dev/null +++ b/core/src/test/java/com/motionapps/sensorbox/core/preferences/DataStoreAppPreferencesRepositoryTest.kt @@ -0,0 +1,114 @@ +package com.motionapps.sensorbox.core.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.motionapps.sensorbox.core.error.AppErrorCode +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class DataStoreAppPreferencesRepositoryTest { + @Test + fun `Given an empty legacy store When preferences are read Then every historical default is preserved`() = runTest { + val repository = DataStoreAppPreferencesRepository(InMemoryPreferencesDataStore()) + + val preferences = repository.preferences.first().getOrNull() + + assertEquals(OnboardingPreferences(), preferences?.onboarding) + assertEquals(RecordingPreferences(), preferences?.recording) + assertEquals(DisplayPreferences(), preferences?.display) + } + + @Test + fun `Given historical keys When preferences round trip Then names and values stay compatible`() = runTest { + val dataStore = InMemoryPreferencesDataStore( + mutablePreferencesOf( + booleanPreferencesKey("completed_intro") to true, + booleanPreferencesKey("accepted_policy") to true, + intPreferencesKey("gps_interval_seconds") to 33, + intPreferencesKey("gps_min_distance_meters") to 7, + intPreferencesKey("sensor_sampling_period") to 2, + booleanPreferencesKey("restrict_on_low_battery") to false, + booleanPreferencesKey("use_wake_lock") to true, + booleanPreferencesKey("keep_phone_display_on") to true, + booleanPreferencesKey("keep_wear_display_on") to true, + ), + ) + val repository = DataStoreAppPreferencesRepository(dataStore) + + val before = repository.preferences.first().getOrNull() + val update = repository.dispatch(AppPreferencesIntent.SetGpsInterval(44)) + val after = repository.preferences.first().getOrNull() + + assertTrue(update.isSuccess) + assertTrue(before?.onboarding?.hasCompletedIntro == true) + assertEquals(7, before?.recording?.gpsMinDistanceMeters) + assertFalse(before?.recording?.restrictMeasurementOnLowBattery ?: true) + assertTrue(before?.display?.keepPhoneDisplayOn == true) + assertEquals(44, after?.recording?.gpsIntervalSeconds) + assertEquals(44, dataStore.current[intPreferencesKey("gps_interval_seconds")]) + } + + @Test + fun `Given a read failure When preferences are collected Then a stable preferences error is emitted`() = runTest { + val dataStore = FlowPreferencesDataStore( + flow { throw IOException("read failed") }, + ) + val repository = DataStoreAppPreferencesRepository(dataStore) + + val result = repository.preferences.first() + + assertEquals(AppErrorCode.PREFERENCES, result.errorOrNull()?.code) + } + + @Test + fun `Given cancellation When preferences are collected Then cancellation propagates`() = runTest { + val dataStore = FlowPreferencesDataStore( + flow { awaitCancellation() }, + ) + val repository = DataStoreAppPreferencesRepository(dataStore) + var emitted = false + + val collection = launch { + repository.preferences.collect { emitted = true } + } + runCurrent() + collection.cancelAndJoin() + + assertFalse(emitted) + } + + private class InMemoryPreferencesDataStore(initial: Preferences = mutablePreferencesOf()) : + DataStore { + private val state = MutableStateFlow(initial) + val current: Preferences + get() = state.value + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + state.value = transform(state.value) + return state.value + } + } + + private class FlowPreferencesDataStore(override val data: Flow) : DataStore { + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = + error("updateData is not used by this test") + } +} diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt index 082733a..7d2b3e5 100644 --- a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/AppPreferencesFixtures.kt @@ -1,10 +1,13 @@ package com.motionapps.sensorbox.core.testing import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.RecordingPreferences object AppPreferencesFixtures { fun preferences(gpsIntervalSeconds: Int = 10, gpsMinDistanceMeters: Int = 20): AppPreferences = AppPreferences( - gpsIntervalSeconds = gpsIntervalSeconds, - gpsMinDistanceMeters = gpsMinDistanceMeters, + recording = RecordingPreferences( + gpsIntervalSeconds = gpsIntervalSeconds, + gpsMinDistanceMeters = gpsMinDistanceMeters, + ), ) } diff --git a/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeEpochClock.kt b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeEpochClock.kt new file mode 100644 index 0000000..c071d7a --- /dev/null +++ b/core/src/testFixtures/java/com/motionapps/sensorbox/core/testing/FakeEpochClock.kt @@ -0,0 +1,7 @@ +package com.motionapps.sensorbox.core.testing + +import com.motionapps.sensorbox.core.time.EpochClock + +class FakeEpochClock(var currentMillis: Long = 0L) : EpochClock { + override fun nowMillis(): Long = currentMillis +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef8e8ab..a94ec03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ detekt = "2.0.0-alpha.6" espresso = "3.7.0" hilt = "2.60.1" kotlin = "2.4.10" +kotlinxDatetime = "0.8.0" +kotlinxSerialization = "1.11.0" ksp = "2.3.10" lifecycle = "2.11.0" junit = "4.13.2" @@ -57,6 +59,8 @@ google-material = { module = "com.google.android.material:material", version.ref hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vico" } diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index 234aac3..d88d482 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -43,6 +44,8 @@ dependencies { implementation(libs.play.services.location) implementation(libs.coroutines.core) implementation(libs.hilt.android) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) ksp(libs.hilt.compiler) testImplementation(libs.junit) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/di/StorageModule.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/di/StorageModule.kt new file mode 100644 index 0000000..8c3877e --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/di/StorageModule.kt @@ -0,0 +1,25 @@ +package com.motionapps.sensorservices.di + +import android.content.Context +import com.motionapps.sensorbox.core.storage.DocumentStorage +import com.motionapps.sensorbox.core.storage.NativeDocumentStorage +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorbox.core.time.SystemEpochClock +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 StorageModule { + @Provides + @Singleton + fun provideDocumentStorage(@ApplicationContext context: Context): DocumentStorage = NativeDocumentStorage(context) + + @Provides + @Singleton + fun provideEpochClock(): EpochClock = SystemEpochClock +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt index b63fdbc..48c1c71 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/StorageHandler.kt @@ -1,117 +1,78 @@ package com.motionapps.sensorservices.handlers import android.content.Context -import android.content.Intent import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.core.storage.NativeDocumentStorage -import com.motionapps.sensorservices.R +import com.motionapps.sensorbox.core.storage.DocumentStorage +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.FileOutputStream import java.io.OutputStream -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale +import javax.inject.Inject -/** Native storage operations shared by the foreground measurement service. */ -object StorageHandler { - fun getDate(milliseconds: Long, format: String = "dd. MM. yyyy HH:mm:ss"): String { - val calendar = Calendar.getInstance().apply { timeInMillis = milliseconds } - return SimpleDateFormat(format, Locale.getDefault()).format(calendar.time) - } +internal interface MeasurementStorage { + fun createMeasurementDirectory(folderName: String, useInternalStorage: Boolean): AppResult - fun createMainFolder(context: Context, intent: Intent?): AppResult { - val directoryName = context.getString(R.string.app_name) - return if (intent == null) { - NativeDocumentStorage.hasAppDirectory(context, directoryName).flatMap { exists -> - if (exists) { + fun openMeasurementFile( + folderName: String, + mimeType: String, + fileName: String, + useInternalStorage: Boolean, + ): AppResult +} + +/** Storage adapter for files created during a recording session. */ +internal class StorageHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val documentStorage: DocumentStorage, +) : MeasurementStorage { + override fun createMeasurementDirectory(folderName: String, useInternalStorage: Boolean): AppResult = + if (useInternalStorage) { + val directory = internalMeasurementDirectory(folderName) + appResult(AppErrorCode.STORAGE, "Create internal measurement directory") { + directory.exists() || directory.mkdirs() + }.flatMap { created -> + if (created) { AppResult.success(Unit) } else { - AppResult.failure( - AppError(AppErrorCode.STORAGE, "Storage directory is not configured"), - ) + AppResult.failure(AppError(AppErrorCode.STORAGE, "Create internal measurement directory")) } } } else { - NativeDocumentStorage.persistRootAccess(context, intent, directoryName) - } - } - - fun isFolder(context: Context): AppResult = NativeDocumentStorage.hasAppDirectory( - context = context, - appDirectoryName = context.getString(R.string.app_name), - ) - - fun isAccess(context: Context): AppResult = isFolder(context) - - fun getFolderName(context: Context): AppResult = NativeDocumentStorage.displayPath( - context = context, - appDirectoryName = context.getString(R.string.app_name), - ).map { it ?: context.getString(R.string.no_path) } - - fun createInternalStorageMeasurementFolder(context: Context, folderName: String): AppResult { - val directory = internalMeasurementDirectory(context, folderName) - return appResult(AppErrorCode.STORAGE, "Create internal measurement directory") { - directory.exists() || directory.mkdirs() - }.flatMap { created -> - if (created) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), - ) - } + documentStorage.createMeasurementDirectory(folderName) } - } - - fun createFolderMeasurement(context: Context, folderName: String): AppResult = - NativeDocumentStorage.createMeasurementDirectory( - context = context, - appDirectoryName = context.getString(R.string.app_name), - measurementName = folderName, - ) - fun createFileInFolder( - context: Context, + override fun openMeasurementFile( folderName: String, - mimeOfNewFile: String, - nameOfNewFile: String, - ): AppResult = NativeDocumentStorage.openMeasurementFile( - context = context, - appDirectoryName = context.getString(R.string.app_name), - measurementName = folderName, - mimeType = mimeOfNewFile, - fileName = nameOfNewFile, - ) - - fun createFileInInternalFolder(context: Context, folderName: String, nameOfFile: String): AppResult = + mimeType: String, + fileName: String, + useInternalStorage: Boolean, + ): AppResult = if (useInternalStorage) { appResult(AppErrorCode.STORAGE, "Prepare internal measurement directory") { - val directory = internalMeasurementDirectory(context, folderName) + val directory = internalMeasurementDirectory(folderName) directory to (directory.exists() || directory.mkdirs()) }.flatMap { (directory, ready) -> if (!ready) { - AppResult.failure( - AppError(AppErrorCode.STORAGE, "Create internal measurement directory"), - ) + AppResult.failure(AppError(AppErrorCode.STORAGE, "Create internal measurement directory")) } else { appResult(AppErrorCode.STORAGE, "Open internal measurement file") { - FileOutputStream(File(directory, nameOfFile)) + FileOutputStream(File(directory, fileName)) } } } - - fun deleteByNameOfFolder(context: Context, deleteFolder: String): AppResult = - NativeDocumentStorage.deleteMeasurement( - context = context, - appDirectoryName = context.getString(R.string.app_name), - measurementName = deleteFolder, + } else { + documentStorage.openMeasurementFile( + measurementName = folderName, + mimeType = mimeType, + fileName = fileName, ) + } - private fun internalMeasurementDirectory(context: Context, folderName: String): File { - val appDirectory = File(context.filesDir, context.getString(R.string.app_name)) + private fun internalMeasurementDirectory(folderName: String): File { + val appDirectory = File(context.filesDir, context.getString(com.motionapps.sensorservices.R.string.app_name)) return File(appDirectory, folderName) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt index acee0df..737087d 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt @@ -1,22 +1,5 @@ package com.motionapps.sensorservices.handlers.measurements -import android.Manifest -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import com.google.android.gms.location.ActivityRecognition -import com.google.android.gms.location.ActivityRecognitionClient -import com.google.android.gms.location.ActivityRecognitionResult -import com.google.android.gms.location.ActivityTransition -import com.google.android.gms.location.ActivityTransitionRequest -import com.google.android.gms.location.ActivityTransitionResult -import com.google.android.gms.location.DetectedActivity import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.AppResult @@ -24,148 +7,86 @@ import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.handlers.MeasurementStorage import java.io.OutputStream -/** Records periodic Google Activity Recognition confidence values. */ -class ActivityRecognitionMeasurement(private val periodSeconds: Int) { - private var client: ActivityRecognitionClient? = null - private var updatesPendingIntent: PendingIntent? = null - private var transitionsPendingIntent: PendingIntent? = null +/** Records periodic activity-recognition confidence values. */ +internal class ActivityRecognitionMeasurement( + private val periodSeconds: Int, + private val storage: MeasurementStorage, + private val platform: ActivityRecognitionPlatform, +) { private var updatesOutput: OutputStream? = null private var transitionsOutput: OutputStream? = null - private var receiverRegistered = false private var writeFailure: AppError? = null - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_UPDATE -> writeActivityUpdate(intent) - ACTION_TRANSITION -> writeActivityTransitions(intent) - } - } - } + fun prepare(folderName: String, useInternalStorage: Boolean): AppResult { + val updatesResult = storage.openMeasurementFile( + folderName = folderName, + mimeType = "text/csv", + fileName = UPDATES_FILE_NAME, + useInternalStorage = useInternalStorage, + ) + val updates = updatesResult.getOrNull() + ?: return AppResult.failure(checkNotNull(updatesResult.errorOrNull())) + .withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") + updatesOutput = updates - private fun writeActivityUpdate(intent: Intent) { - val result = ActivityRecognitionResult.extractResult(intent) ?: return - val confidences = ACTIVITIES.joinToString(";") { result.getActivityConfidence(it).toString() } - recordWriteFailure("Write activity update") { - updatesOutput?.write("${result.elapsedRealtimeMillis};$confidences\n".toByteArray()) + val transitionsResult = storage.openMeasurementFile( + folderName = folderName, + mimeType = "text/csv", + fileName = TRANSITIONS_FILE_NAME, + useInternalStorage = useInternalStorage, + ) + val transitions = transitionsResult.getOrNull() + if (transitions == null) { + appResult(AppErrorCode.STORAGE, "Close incomplete activity measurement") { updates.close() } + updatesOutput = null + return AppResult.failure(checkNotNull(transitionsResult.errorOrNull())) + .withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") } - } + transitionsOutput = transitions - private fun writeActivityTransitions(intent: Intent) { - val result = ActivityTransitionResult.extractResult(intent) ?: return - recordWriteFailure("Write activity transition") { - result.transitionEvents.forEach { transition -> - transitionsOutput?.write( - "${transition.elapsedRealTimeNanos};${transition.activityType};${transition.transitionType}\n" - .toByteArray(), - ) - } + val headers = appResult(AppErrorCode.STORAGE, "Write activity recognition headers") { + updates.write(UPDATES_HEADER.toByteArray()) + transitions.write(TRANSITIONS_HEADER.toByteArray()) } - } - - fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { - val updatesResult = if (useInternalStorage) { - StorageHandler.createFileInInternalFolder(context, folderName, UPDATES_FILE_NAME) - } else { - StorageHandler.createFileInFolder(context, folderName, "text/csv", UPDATES_FILE_NAME) + return headers.flatMap { + platform.prepare(::writeActivityUpdate, ::writeActivityTransitions) } - return updatesResult.flatMap { updates -> - updatesOutput = updates - val transitionsResult = if (useInternalStorage) { - StorageHandler.createFileInInternalFolder(context, folderName, TRANSITIONS_FILE_NAME) - } else { - StorageHandler.createFileInFolder(context, folderName, "text/csv", TRANSITIONS_FILE_NAME) - } - transitionsResult.onFailure { - appResult(AppErrorCode.STORAGE, "Close incomplete activity measurement") { updates.close() } - }.flatMap { transitions -> - transitionsOutput = transitions - initializeResources(context) - } - }.withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") + .withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") } - private fun initializeResources(context: Context): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Initialize activity recognition resources", - ) { - updatesOutput?.write( - "t_elapsed;still;on_foot;walking;running;vehicle;bike;unknown;tilting\n".toByteArray(), - ) - transitionsOutput?.write("t_nanos;activity;enter_exit\n".toByteArray()) - val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } - ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) - receiverRegistered = true - client = ActivityRecognition.getClient(context) - updatesPendingIntent = PendingIntent.getBroadcast( - context, - UPDATES_REQUEST_CODE, - Intent(ACTION_UPDATE).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - transitionsPendingIntent = PendingIntent.getBroadcast( - context, - TRANSITIONS_REQUEST_CODE, - Intent(ACTION_TRANSITION).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + fun start(): AppResult = platform.start(periodSeconds) + .withAppError(AppErrorCode.MEASUREMENT, "Start activity recognition") + + suspend fun stop(): AppResult { + val results = listOf(platform.stop(), save()) + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop activity recognition") } - @SuppressLint("MissingPermission") - fun start(context: Context): AppResult { - if (!hasPermission(context)) { - return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) - } - return appResult(AppErrorCode.MEASUREMENT, "Start activity recognition") { - updatesPendingIntent?.let { - client?.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, it) - ?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Request activity updates", error) - } - } - transitionsPendingIntent?.let { - client?.requestActivityTransitionUpdates(ActivityTransitionRequest(ACTIVITY_TRANSITIONS), it) - ?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Request activity transitions", error) - } - } + private fun writeActivityUpdate(update: ActivityUpdate) { + recordWriteFailure("Write activity update") { + val confidences = update.confidences.joinToString(";") + updatesOutput?.write("${update.elapsedRealtimeMillis};$confidences\n".toByteArray()) } } - @SuppressLint("MissingPermission") - private fun pause(context: Context): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Pause activity recognition", - ) { - if (hasPermission(context)) { - updatesPendingIntent?.let { - client?.removeActivityUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Remove activity updates", error) - } - } - transitionsPendingIntent?.let { - client?.removeActivityTransitionUpdates(it)?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Remove activity transitions", error) - } + private fun writeActivityTransitions(transitions: List) { + recordWriteFailure("Write activity transition") { + transitions.forEach { transition -> + transitionsOutput?.write( + "${transition.elapsedRealtimeNanos};${transition.activityType};${transition.transitionType}\n" + .toByteArray(), + ) } } - if (receiverRegistered) { - context.unregisterReceiver(receiver) - } - receiverRegistered = false } private suspend fun save(): AppResult { val results = mutableListOf>() - results += appResult(AppErrorCode.STORAGE, "Close activity updates") { - updatesOutput?.close() - } - results += appResult(AppErrorCode.STORAGE, "Close activity transitions") { - transitionsOutput?.close() - } + results += appResult(AppErrorCode.STORAGE, "Close activity updates") { updatesOutput?.close() } + results += appResult(AppErrorCode.STORAGE, "Close activity transitions") { transitionsOutput?.close() } writeFailure?.let { results += AppResult.failure(it) } updatesOutput = null transitionsOutput = null @@ -173,53 +94,15 @@ class ActivityRecognitionMeasurement(private val periodSeconds: Int) { return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save activity recognition") } - suspend fun stop(context: Context): AppResult { - val results = listOf(pause(context), save()) - updatesPendingIntent = null - transitionsPendingIntent = null - client = null - return results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop activity recognition") - } - private inline fun recordWriteFailure(operation: String, block: () -> Unit) { if (writeFailure != null) return appResult(AppErrorCode.STORAGE, operation, block).onFailure { writeFailure = it } } - private fun hasPermission(context: Context): Boolean = Build.VERSION.SDK_INT < 29 || - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACTIVITY_RECOGNITION, - ) == PackageManager.PERMISSION_GRANTED - private companion object { - const val ACTION_UPDATE = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_UPDATE" - const val ACTION_TRANSITION = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" - const val UPDATES_REQUEST_CODE = 457 - const val TRANSITIONS_REQUEST_CODE = 1_654 const val UPDATES_FILE_NAME = "activity_updates.csv" const val TRANSITIONS_FILE_NAME = "activity_transitions.csv" - val ACTIVITIES = intArrayOf( - DetectedActivity.STILL, - DetectedActivity.ON_FOOT, - DetectedActivity.WALKING, - DetectedActivity.RUNNING, - DetectedActivity.IN_VEHICLE, - DetectedActivity.ON_BICYCLE, - DetectedActivity.UNKNOWN, - DetectedActivity.TILTING, - ) - val ACTIVITY_TRANSITIONS = ACTIVITIES.flatMap { activity -> - listOf( - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) - .build(), - ActivityTransition.Builder() - .setActivityType(activity) - .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) - .build(), - ) - } + const val UPDATES_HEADER = "t_elapsed;still;on_foot;walking;running;vehicle;bike;unknown;tilting\n" + const val TRANSITIONS_HEADER = "t_nanos;activity;enter_exit\n" } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt new file mode 100644 index 0000000..53aaa8a --- /dev/null +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt @@ -0,0 +1,165 @@ +package com.motionapps.sensorservices.handlers.measurements + +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityRecognitionClient +import com.google.android.gms.location.ActivityRecognitionResult +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionRequest +import com.google.android.gms.location.ActivityTransitionResult +import com.google.android.gms.location.DetectedActivity +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 + +internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) + +internal data class ActivityTransitionSample( + val elapsedRealtimeNanos: Long, + val activityType: Int, + val transitionType: Int, +) + +internal interface ActivityRecognitionPlatform { + fun prepare( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult + + fun start(periodSeconds: Int): AppResult + + fun stop(): AppResult +} + +internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { + private var client: ActivityRecognitionClient? = null + private var updatesPendingIntent: PendingIntent? = null + private var transitionsPendingIntent: PendingIntent? = null + private var receiverRegistered = false + private var updateCallback: ((ActivityUpdate) -> Unit)? = null + private var transitionCallback: ((List) -> Unit)? = null + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_UPDATE -> ActivityRecognitionResult.extractResult(intent)?.let { result -> + updateCallback?.invoke( + ActivityUpdate( + elapsedRealtimeMillis = result.elapsedRealtimeMillis, + confidences = ACTIVITIES.map(result::getActivityConfidence), + ), + ) + } + + ACTION_TRANSITION -> ActivityTransitionResult.extractResult(intent)?.let { result -> + transitionCallback?.invoke( + result.transitionEvents.map { transition -> + ActivityTransitionSample( + elapsedRealtimeNanos = transition.elapsedRealTimeNanos, + activityType = transition.activityType, + transitionType = transition.transitionType, + ) + }, + ) + } + } + } + } + + override fun prepare( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Initialize activity recognition resources") { + updateCallback = onUpdate + transitionCallback = onTransitions + val filter = IntentFilter(ACTION_UPDATE).apply { addAction(ACTION_TRANSITION) } + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + receiverRegistered = true + client = ActivityRecognition.getClient(context) + updatesPendingIntent = PendingIntent.getBroadcast( + context, + UPDATES_REQUEST_CODE, + Intent(ACTION_UPDATE).setPackage(context.packageName), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + transitionsPendingIntent = PendingIntent.getBroadcast( + context, + TRANSITIONS_REQUEST_CODE, + Intent(ACTION_TRANSITION).setPackage(context.packageName), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + @SuppressLint("MissingPermission") + override fun start(periodSeconds: Int): AppResult { + if (!hasPermission()) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) + } + return appResult(AppErrorCode.MEASUREMENT, "Start activity recognition") { + updatesPendingIntent?.let { pendingIntent -> + client?.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, pendingIntent) + } + transitionsPendingIntent?.let { pendingIntent -> + client?.requestActivityTransitionUpdates(ActivityTransitionRequest(ACTIVITY_TRANSITIONS), pendingIntent) + } + } + } + + @SuppressLint("MissingPermission") + override fun stop(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Pause activity recognition") { + if (hasPermission()) { + updatesPendingIntent?.let { client?.removeActivityUpdates(it) } + transitionsPendingIntent?.let { client?.removeActivityTransitionUpdates(it) } + } + if (receiverRegistered) context.unregisterReceiver(receiver) + receiverRegistered = false + client = null + updatesPendingIntent = null + transitionsPendingIntent = null + updateCallback = null + transitionCallback = null + } + + private fun hasPermission(): Boolean = Build.VERSION.SDK_INT < 29 || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == + PackageManager.PERMISSION_GRANTED + + private companion object { + const val ACTION_UPDATE = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_UPDATE" + const val ACTION_TRANSITION = "com.motionapps.sensorbox.ACTIVITY_RECOGNITION_TRANSITION" + const val UPDATES_REQUEST_CODE = 457 + const val TRANSITIONS_REQUEST_CODE = 1_654 + val ACTIVITIES = intArrayOf( + DetectedActivity.STILL, + DetectedActivity.ON_FOOT, + DetectedActivity.WALKING, + DetectedActivity.RUNNING, + DetectedActivity.IN_VEHICLE, + DetectedActivity.ON_BICYCLE, + DetectedActivity.UNKNOWN, + DetectedActivity.TILTING, + ) + val ACTIVITY_TRANSITIONS = ACTIVITIES.flatMap { activity -> + listOf( + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) + .build(), + ActivityTransition.Builder() + .setActivityType(activity) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT) + .build(), + ) + } + } +} diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt index d2c3fa5..437532b 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -9,23 +9,28 @@ 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.error.withAppError -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorservices.handlers.MeasurementStorage import com.motionapps.sensorservices.serviceController.MeasurementConfig -import org.json.JSONArray -import org.json.JSONObject +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** Collects session metadata and writes it once when the recording is closed. */ -class ExtraInfoHandler { +internal class ExtraInfoHandler(private val storage: MeasurementStorage, private val clock: EpochClock) { private var config: MeasurementConfig? = null private var startedAtMillis: Long = 0L private var startedAtNanos: Long = 0L - private val annotations = mutableListOf() + private val annotations = mutableListOf() private val triggeredAlarms = mutableListOf() private var written = false fun start(config: MeasurementConfig) { this.config = config - startedAtMillis = System.currentTimeMillis() + startedAtMillis = clock.nowMillis() startedAtNanos = SystemClock.elapsedRealtimeNanos() annotations.clear() triggeredAlarms.clear() @@ -33,10 +38,10 @@ class ExtraInfoHandler { } fun annotate(timestampMillis: Long, text: String) { - text.trim().takeIf(String::isNotEmpty)?.let { annotations += Annotation(timestampMillis, it) } + text.trim().takeIf(String::isNotEmpty)?.let { annotations += MeasurementAnnotation(timestampMillis, it) } } - fun alarmTriggered(timestampMillis: Long = System.currentTimeMillis()) { + fun alarmTriggered(timestampMillis: Long = clock.nowMillis()) { triggeredAlarms += timestampMillis } @@ -44,72 +49,81 @@ class ExtraInfoHandler { val active = config ?: return AppResult.success(Unit) if (written) return AppResult.success(Unit) written = true - val jsonResult = appResult(AppErrorCode.MEASUREMENT, "Build measurement metadata") { - JSONObject().apply { - put("millis", startedAtMillis) - put("nanos", startedAtNanos) - put("type", active.measurementType) - put("date", StorageHandler.getDate(startedAtMillis)) - put("folder", active.folderName) - put("notes", JSONArray(active.notes)) - put( - "annotations", - JSONArray().apply { - annotations.forEach { annotation -> - put( - JSONObject() - .put("timestamp", annotation.timestampMillis) - .put("annotation", annotation.text), - ) - } - }, - ) - val rangeIds = if (active.significantMotion) { - active.sensorIds + Sensor.TYPE_SIGNIFICANT_MOTION - } else { - active.sensorIds - } - put("ranges", sensorRanges(context, rangeIds)) - put("alarms", JSONArray(triggeredAlarms)) - put("configuredAlarmOffsetsSeconds", JSONArray(active.alarmOffsetsSeconds)) - put("durationMillis", active.durationMillis) - put("activityRecognition", active.activityRecognition) - put("significantMotion", active.significantMotion) - } + val metadataResult = appResult(AppErrorCode.MEASUREMENT, "Build measurement metadata") { + MeasurementMetadata( + millis = startedAtMillis, + nanos = startedAtNanos, + type = active.measurementType, + date = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()).format(Date(startedAtMillis)), + folder = active.folderName, + notes = active.notes, + annotations = annotations.toList(), + ranges = sensorRanges(context, active), + alarms = triggeredAlarms.toList(), + configuredAlarmOffsetsSeconds = active.alarmOffsetsSeconds.toList(), + durationMillis = active.durationMillis, + activityRecognition = active.activityRecognition, + significantMotion = active.significantMotion, + ) } - return jsonResult.flatMap { json -> - val stream = if (active.useInternalStorage) { - StorageHandler.createFileInInternalFolder(context, active.folderName, EXTRA_FILE) - } else { - StorageHandler.createFileInFolder(context, active.folderName, "application/json", EXTRA_FILE) - } - stream.flatMap { output -> + return metadataResult.flatMap { metadata -> + storage.openMeasurementFile( + folderName = active.folderName, + mimeType = "application/json", + fileName = EXTRA_FILE, + useInternalStorage = active.useInternalStorage, + ).flatMap { output -> appResult(AppErrorCode.STORAGE, "Write measurement metadata file") { - output.use { it.write(json.toString(2).toByteArray()) } + output.use { it.write(JSON.encodeToString(metadata).toByteArray()) } } } }.withAppError(AppErrorCode.MEASUREMENT, "Write measurement metadata") } - private fun sensorRanges(context: Context, sensorIds: IntArray): JSONArray { + private fun sensorRanges(context: Context, active: MeasurementConfig): List { val manager = context.getSystemService(SensorManager::class.java) - return JSONArray().apply { - sensorIds.distinct().forEach { type -> - manager.getDefaultSensor(type)?.let { sensor -> - put( - JSONObject() - .put("sensor", sensor.name) - .put("type", type) - .put("range", sensor.maximumRange), - ) - } + val rangeIds = if (active.significantMotion) { + active.sensorIds + Sensor.TYPE_SIGNIFICANT_MOTION + } else { + active.sensorIds + } + return rangeIds.distinct().mapNotNull { type -> + manager.getDefaultSensor(type)?.let { sensor -> + SensorRange(sensor = sensor.name, type = type, range = sensor.maximumRange) } } } - private data class Annotation(val timestampMillis: Long, val text: String) - private companion object { + const val DATE_FORMAT = "dd. MM. yyyy HH:mm:ss" const val EXTRA_FILE = "extra.json" + val JSON = Json { + prettyPrint = true + prettyPrintIndent = " " + encodeDefaults = true + } } } + +@Serializable +internal data class MeasurementMetadata( + val millis: Long, + val nanos: Long, + val type: String, + val date: String, + val folder: String, + val notes: List, + val annotations: List, + val ranges: List, + val alarms: List, + val configuredAlarmOffsetsSeconds: List, + val durationMillis: Long, + val activityRecognition: Boolean, + val significantMotion: Boolean, +) + +@Serializable +internal data class MeasurementAnnotation(val timestamp: Long, val annotation: String) + +@Serializable +internal data class SensorRange(val sensor: String, val type: Int, val range: Float) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt index a073eb4..f5bb2ce 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/GPSMeasurement.kt @@ -8,10 +8,10 @@ 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.combineAppResults -import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorservices.handlers.GPSHandler -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.handlers.MeasurementStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.OutputStream @@ -21,7 +21,11 @@ import java.io.OutputStream * * @property gpsHandler - manages access to GPS */ -class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandler.OnLocationChangedCallback { +internal class GPSMeasurement( + private val gpsHandler: GPSHandler, + private val storage: MeasurementStorage, + private val clock: EpochClock, +) : GPSHandler.OnLocationChangedCallback { private var outputStream: OutputStream? = null private var writeFailure: AppError? = null @@ -34,7 +38,6 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandle * @param params - from the service */ fun prepare( - context: Context, folderName: String, useInternalStorage: Boolean, intervalSeconds: Int, @@ -44,24 +47,20 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandle intervalSeconds = intervalSeconds, minDistanceMeters = minimumDistanceMeters, ) - val stream = if (useInternalStorage) { - StorageHandler.createFileInInternalFolder( - context, - folderName, - "gps.csv", - ) - } else { - StorageHandler.createFileInFolder( - context, - folderName, - "csv", - "gps.csv", - ) + val streamResult = storage.openMeasurementFile( + folderName = folderName, + mimeType = "text/csv", + fileName = "gps.csv", + useInternalStorage = useInternalStorage, + ) + val output = streamResult.getOrNull() + ?: return AppResult.failure(checkNotNull(streamResult.errorOrNull())) + .withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") + outputStream = output + val headerResult = appResult(AppErrorCode.STORAGE, "Write GPS header") { + output.write(header.toByteArray()) } - return stream.flatMap { output -> - outputStream = output - appResult(AppErrorCode.STORAGE, "Write GPS header") { output.write(header.toByteArray()) } - }.withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") + return headerResult.withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") } /** @@ -70,7 +69,7 @@ class GPSMeasurement constructor(private val gpsHandler: GPSHandler) : GPSHandle * @param location - location from the GPS * @return - formatted line of the csv */ - private fun createLocationStamp(location: Location): String = "${System.currentTimeMillis()};" + + private fun createLocationStamp(location: Location): String = "${clock.nowMillis()};" + "${location.latitude};" + "${location.longitude};" + "${location.altitude};" + diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt index e465989..891471b 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SensorMeasurement.kt @@ -5,89 +5,86 @@ import android.hardware.SensorManager 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.appResult import com.motionapps.sensorbox.core.error.combineAppResults -import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorservices.handlers.MeasurementStorage import com.motionapps.sensorservices.types.SensorHolder import com.motionapps.sensorservices.types.SensorSpec -class SensorMeasurement { +internal class SensorMeasurement( + private val storage: MeasurementStorage, + private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, +) { private val holders = mutableListOf() private var samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST fun prepare( - context: Context, folderName: String, useInternalStorage: Boolean, sensorTypes: Set, samplingPeriod: Int, ): AppResult { this.samplingPeriod = samplingPeriod - return sensorTypes.sorted().fold( - AppResult.success(Unit), - ) { result, sensorType -> - result.flatMap { - createHolder(context, folderName, useInternalStorage, sensorType).map { holder -> - holder?.let(holders::add) - Unit - } + for (sensorType in sensorTypes.sorted()) { + val holderResult = createHolder(folderName, useInternalStorage, sensorType) + if (holderResult.isFailure) { + return AppResult.failure(checkNotNull(holderResult.errorOrNull())) + .withAppError(AppErrorCode.MEASUREMENT, "Initialize sensors") } + holderResult.getOrNull()?.let(holders::add) } - .withAppError(AppErrorCode.MEASUREMENT, "Initialize sensors") + return AppResult.success(Unit) } private fun createHolder( - context: Context, folderName: String, useInternalStorage: Boolean, sensorType: Int, ): AppResult { val spec = SensorSpec.fromType(sensorType) ?: return AppResult.success(null) - val stream = if (useInternalStorage) { - StorageHandler.createFileInInternalFolder( - context, - folderName, - spec.fileName, - ) - } else { - StorageHandler.createFileInFolder( - context, - folderName, - "text/csv", - spec.fileName, - ) - } - return stream.map { SensorHolder(spec, it) } + return storage.openMeasurementFile( + folderName = folderName, + mimeType = "text/csv", + fileName = spec.fileName, + useInternalStorage = useInternalStorage, + ).map { SensorHolder(spec, it, diagnosticLogger, clock) } } - fun start(context: Context): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Access sensor manager", - ) { - context.getSystemService(SensorManager::class.java) - }.flatMap { sensorManager -> - holders.fold(AppResult.success(Unit)) { result, holder -> - result.flatMap { - val sensor = sensorManager.getDefaultSensor(holder.spec.type) - ?: return@flatMap AppResult.failure( - AppError(AppErrorCode.MEASUREMENT, "Find sensor ${holder.spec.type}"), - ) - appResult(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}") { - sensorManager.registerListener(holder, sensor, samplingPeriod) - }.flatMap { registered -> - if (registered) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}"), - ) - } - } + fun start(context: Context): AppResult { + val managerResult = appResult(AppErrorCode.MEASUREMENT, "Access sensor manager") { + context.getSystemService(SensorManager::class.java) + } + val sensorManager = managerResult.getOrNull() + ?: return AppResult.failure(checkNotNull(managerResult.errorOrNull())) + .withAppError(AppErrorCode.MEASUREMENT, "Start sensors") + for (holder in holders) { + val registration = registerHolder(sensorManager, holder) + if (registration.isFailure) { + return registration.withAppError(AppErrorCode.MEASUREMENT, "Start sensors") } } - }.withAppError(AppErrorCode.MEASUREMENT, "Start sensors") + return AppResult.success(Unit) + } + + private fun registerHolder(sensorManager: SensorManager, holder: SensorHolder): AppResult { + val sensor = sensorManager.getDefaultSensor(holder.spec.type) + ?: return AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Find sensor ${holder.spec.type}")) + val registration = appResult(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}") { + sensorManager.registerListener(holder, sensor, samplingPeriod) + } + return if (registration.getOrNull() == true) { + AppResult.success(Unit) + } else { + AppResult.failure( + registration.errorOrNull() + ?: AppError(AppErrorCode.MEASUREMENT, "Register sensor ${holder.spec.type}"), + ) + } + } private fun pause(context: Context): AppResult = appResult( AppErrorCode.MEASUREMENT, diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt index 220a4a7..d370784 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/SignificantMotion.kt @@ -12,11 +12,13 @@ import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.flatMap import com.motionapps.sensorbox.core.error.withAppError -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorservices.handlers.MeasurementStorage import java.io.OutputStream /** Handles Android's one-shot significant-motion trigger and re-arms it after every event. */ -class SignificantMotion : TriggerEventListener() { +internal class SignificantMotion(private val storage: MeasurementStorage, private val clock: EpochClock) : + TriggerEventListener() { private var sensorManager: SensorManager? = null private var sensor: Sensor? = null private var output: OutputStream? = null @@ -25,11 +27,12 @@ class SignificantMotion : TriggerEventListener() { fun prepare(context: Context, folderName: String, useInternalStorage: Boolean): AppResult { sensorManager = context.getSystemService(SensorManager::class.java) sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) - val stream = if (useInternalStorage) { - StorageHandler.createFileInInternalFolder(context, folderName, FILE_NAME) - } else { - StorageHandler.createFileInFolder(context, folderName, "text/csv", FILE_NAME) - } + val stream = storage.openMeasurementFile( + folderName = folderName, + mimeType = "text/csv", + fileName = FILE_NAME, + useInternalStorage = useInternalStorage, + ) return stream.flatMap { opened -> output = opened appResult(AppErrorCode.STORAGE, "Write significant motion header") { @@ -70,7 +73,7 @@ class SignificantMotion : TriggerEventListener() { override fun onTrigger(event: TriggerEvent?) { event?.values?.firstOrNull()?.let { value -> appResult(AppErrorCode.STORAGE, "Write significant motion") { - output?.write("${System.currentTimeMillis()};$value\n".toByteArray()) + output?.write("${clock.nowMillis()};$value\n".toByteArray()) }.onFailure { writeFailure = it } } if (!arm()) AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion") diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt index 1f9ab0d..f14a7a0 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementIntentFactory.kt @@ -2,12 +2,18 @@ package com.motionapps.sensorservices.intent import android.content.Context import android.content.Intent -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorservices.services.MeasurementService import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import javax.inject.Inject -class MeasurementIntentFactory @Inject constructor(@ApplicationContext private val context: Context) { +class MeasurementIntentFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val clock: EpochClock, +) { fun create(request: MeasurementLaunchRequest): Intent = Intent(context, MeasurementService::class.java).apply { putExtra(MeasurementService.SESSION_ID, request.sessionId) putExtra(MeasurementService.FOLDER_NAME, request.folderName) @@ -32,7 +38,8 @@ class MeasurementIntentFactory @Inject constructor(@ApplicationContext private v fun newFolderName(customName: String = "", measurementType: String = "ENDLESS"): String { val prefix = customName.trim().replace(INVALID_NAME_CHARS, "_").trim('_').take(MAX_PREFIX_LENGTH) .ifBlank { if (measurementType == "TIMED") "timed" else "recording" } - return "${prefix}_${StorageHandler.getDate(System.currentTimeMillis(), DATE_FORMAT)}" + val timestamp = SimpleDateFormat(DATE_FORMAT, Locale.getDefault()).format(Date(clock.nowMillis())) + return "${prefix}_$timestamp" } private companion object { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt index 73c9115..b4e31cc 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/intent/MeasurementLaunchRequest.kt @@ -1,5 +1,7 @@ package com.motionapps.sensorservices.intent +import com.motionapps.sensorbox.core.time.SystemEpochClock + data class MeasurementLaunchRequest( val sessionId: String = java.util.UUID.randomUUID().toString(), val folderName: String, @@ -12,7 +14,7 @@ data class MeasurementLaunchRequest( val gpsIntervalSeconds: Int, val gpsMinDistanceMeters: Int, val measurementType: String = "ENDLESS", - val startAtEpochMillis: Long = System.currentTimeMillis(), + val startAtEpochMillis: Long = SystemEpochClock.nowMillis(), val durationMillis: Long = 0L, val notes: List = emptyList(), val alarmOffsetsSeconds: List = emptyList(), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt index 6d55c38..728d2b8 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/AndroidRecordingSources.kt @@ -6,28 +6,38 @@ import android.media.ToneGenerator 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.appResult import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorbox.recording.RecordingSource import com.motionapps.sensorbox.recording.RecordingSourceSpec import com.motionapps.sensorbox.recording.RecordingSourceType import com.motionapps.sensorservices.handlers.GPSHandler -import com.motionapps.sensorservices.handlers.StorageHandler +import com.motionapps.sensorservices.handlers.MeasurementStorage import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionMeasurement +import com.motionapps.sensorservices.handlers.measurements.ActivityRecognitionPlatform +import com.motionapps.sensorservices.handlers.measurements.AndroidActivityRecognitionPlatform import com.motionapps.sensorservices.handlers.measurements.ExtraInfoHandler import com.motionapps.sensorservices.handlers.measurements.GPSMeasurement import com.motionapps.sensorservices.handlers.measurements.SensorMeasurement import com.motionapps.sensorservices.handlers.measurements.SignificantMotion -class AndroidRecordingSources(private val context: Context, private val config: MeasurementConfig) { - private val artifacts = SessionArtifacts(context, config) +internal class AndroidRecordingSources( + private val context: Context, + private val config: MeasurementConfig, + storage: MeasurementStorage, + diagnosticLogger: DiagnosticLogger, + clock: EpochClock, +) { + private val artifacts = SessionArtifacts(context, config, storage, clock) val sources: List = listOf( SessionSource(artifacts), - SensorSource(context, config), - GpsSource(context, config), - ActivitySource(context, config), - SignificantMotionSource(context, config), + SensorSource(context, config, storage, diagnosticLogger, clock), + GpsSource(context, config, storage, clock), + ActivitySource(config, storage, AndroidActivityRecognitionPlatform(context)), + SignificantMotionSource(context, config, storage, clock), ) fun annotate(timestampMillis: Long, text: String): AppResult = artifacts.annotate(timestampMillis, text) @@ -46,14 +56,19 @@ private class SessionSource(private val artifacts: SessionArtifacts) : Recording override suspend fun stop(): AppResult = artifacts.stop() } -private class SensorSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { - private val measurement = SensorMeasurement() +private class SensorSource( + private val context: Context, + private val config: MeasurementConfig, + storage: MeasurementStorage, + diagnosticLogger: DiagnosticLogger, + clock: EpochClock, +) : RecordingSource { + private val measurement = SensorMeasurement(storage, diagnosticLogger, clock) override val type = RecordingSourceType.SENSOR override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Sensors) { measurement.prepare( - context = context, folderName = config.folderName, useInternalStorage = config.useInternalStorage, sensorTypes = spec.sensorTypes, @@ -68,13 +83,17 @@ private class SensorSource(private val context: Context, private val config: Mea override suspend fun stop(): AppResult = measurement.stop(context) } -private class GpsSource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { - private val measurement = GPSMeasurement(GPSHandler()) +private class GpsSource( + private val context: Context, + private val config: MeasurementConfig, + storage: MeasurementStorage, + clock: EpochClock, +) : RecordingSource { + private val measurement = GPSMeasurement(GPSHandler(), storage, clock) override val type = RecordingSourceType.GPS override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { measurement.prepare( - context = context, folderName = config.folderName, useInternalStorage = config.useInternalStorage, intervalSeconds = spec.intervalSeconds, @@ -89,14 +108,17 @@ private class GpsSource(private val context: Context, private val config: Measur override suspend fun stop(): AppResult = measurement.stop() } -private class ActivitySource(private val context: Context, private val config: MeasurementConfig) : RecordingSource { +private class ActivitySource( + private val config: MeasurementConfig, + private val storage: MeasurementStorage, + private val platform: ActivityRecognitionPlatform, +) : RecordingSource { private var measurement: ActivityRecognitionMeasurement? = null override val type = RecordingSourceType.ACTIVITY_RECOGNITION override suspend fun prepare(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.ActivityRecognition) { - ActivityRecognitionMeasurement(spec.periodSeconds).also { measurement = it }.prepare( - context, + ActivityRecognitionMeasurement(spec.periodSeconds, storage, platform).also { measurement = it }.prepare( config.folderName, config.useInternalStorage, ) @@ -104,19 +126,23 @@ private class ActivitySource(private val context: Context, private val config: M invalidSpec(type) } - override suspend fun start(): AppResult = measurement?.start(context) + override suspend fun start(): AppResult = measurement?.start() ?: invalidSpec(type) override suspend fun stop(): AppResult { - val result = measurement?.stop(context) ?: AppResult.success(Unit) + val result = measurement?.stop() ?: AppResult.success(Unit) measurement = null return result } } -private class SignificantMotionSource(private val context: Context, private val config: MeasurementConfig) : - RecordingSource { - private val measurement = SignificantMotion() +private class SignificantMotionSource( + private val context: Context, + private val config: MeasurementConfig, + storage: MeasurementStorage, + clock: EpochClock, +) : RecordingSource { + private val measurement = SignificantMotion(storage, clock) override val type = RecordingSourceType.SIGNIFICANT_MOTION override suspend fun prepare(spec: RecordingSourceSpec): AppResult = @@ -131,16 +157,17 @@ private class SignificantMotionSource(private val context: Context, private val override suspend fun stop(): AppResult = measurement.stop() } -private class SessionArtifacts(private val context: Context, private val config: MeasurementConfig) { - private val extraInfo = ExtraInfoHandler() +private class SessionArtifacts( + private val context: Context, + private val config: MeasurementConfig, + private val storage: MeasurementStorage, + clock: EpochClock, +) { + private val extraInfo = ExtraInfoHandler(storage, clock) private var toneGenerator: ToneGenerator? = null fun prepare(): AppResult { - val directory = if (config.useInternalStorage) { - StorageHandler.createInternalStorageMeasurementFolder(context, config.folderName) - } else { - StorageHandler.createFolderMeasurement(context, config.folderName) - } + val directory = storage.createMeasurementDirectory(config.folderName, config.useInternalStorage) return directory.onSuccess { extraInfo.start(config) } } diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt index beea66c..9837a55 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/MeasurementConfig.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorservices.serviceController import android.content.Intent import android.hardware.SensorManager +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorservices.services.MeasurementService data class MeasurementConfig( @@ -25,7 +26,7 @@ data class MeasurementConfig( val significantMotion: Boolean, ) { companion object { - fun from(intent: Intent): MeasurementConfig = MeasurementConfig( + fun from(intent: Intent, clock: EpochClock): MeasurementConfig = MeasurementConfig( sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), @@ -42,7 +43,7 @@ data class MeasurementConfig( measurementType = intent.getStringExtra(MeasurementService.MEASUREMENT_TYPE) ?: "ENDLESS", startAtEpochMillis = intent.getLongExtra( MeasurementService.START_AT_EPOCH_MILLIS, - System.currentTimeMillis(), + clock.nowMillis(), ), durationMillis = intent.getLongExtra(MeasurementService.DURATION_MILLIS, 0L).coerceAtLeast(0L), notes = intent.getStringArrayListExtra(MeasurementService.NOTES).orEmpty(), diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt index 6cd0143..dc375e7 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/serviceController/ServiceController.kt @@ -2,6 +2,8 @@ package com.motionapps.sensorservices.serviceController import android.content.Context import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorbox.recording.RecordingClock import com.motionapps.sensorbox.recording.RecordingDelay import com.motionapps.sensorbox.recording.RecordingEngine @@ -10,17 +12,25 @@ import com.motionapps.sensorbox.recording.RecordingPlan import com.motionapps.sensorbox.recording.RecordingSessionId import com.motionapps.sensorbox.recording.RecordingSourceSpec import com.motionapps.sensorbox.recording.RecordingStopReason +import com.motionapps.sensorservices.handlers.MeasurementStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharedFlow -class ServiceController(context: Context, private val config: MeasurementConfig, scope: CoroutineScope) { - private val androidSources = AndroidRecordingSources(context, config) +internal class ServiceController( + context: Context, + private val config: MeasurementConfig, + scope: CoroutineScope, + storage: MeasurementStorage, + diagnosticLogger: DiagnosticLogger, + clock: EpochClock, +) { + private val androidSources = AndroidRecordingSources(context, config, storage, diagnosticLogger, clock) private val sessionId = RecordingSessionId(config.sessionId) private val engine = RecordingEngine( sources = androidSources.sources, scope = scope, - clock = RecordingClock(System::currentTimeMillis), + clock = RecordingClock(clock::nowMillis), delay = RecordingDelay { durationMillis -> delay(durationMillis) }, ) diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt index 7eb3a98..b25fa38 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/services/MeasurementService.kt @@ -13,10 +13,13 @@ import android.os.PowerManager import android.os.SystemClock 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.appResult import com.motionapps.sensorbox.core.error.combineAppResults +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorbox.recording.RecordingEvent import com.motionapps.sensorbox.recording.RecordingStopReason +import com.motionapps.sensorservices.handlers.StorageHandler import com.motionapps.sensorservices.serviceController.MeasurementConfig import com.motionapps.sensorservices.serviceController.ServiceController import com.motionapps.sensorservices.session.MeasurementSessionState @@ -38,6 +41,15 @@ class MeasurementService : Service() { @Inject lateinit var sessionStore: MeasurementSessionStore + @Inject + internal lateinit var storageHandler: StorageHandler + + @Inject + lateinit var diagnosticLogger: DiagnosticLogger + + @Inject + lateinit var epochClock: EpochClock + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var controller: ServiceController? = null private var activeConfig: MeasurementConfig? = null @@ -60,7 +72,7 @@ class MeasurementService : Service() { ACTION_STOP -> requestStop(RecordingStopReason.USER_REQUEST) ACTION_ANNOTATE -> controller?.annotate( - intent.getLongExtra(ANNOTATION_TIME, System.currentTimeMillis()), + intent.getLongExtra(ANNOTATION_TIME, epochClock.nowMillis()), intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), ) @@ -71,13 +83,20 @@ class MeasurementService : Service() { private fun startRecordingHost(intent: Intent) { appResult(AppErrorCode.MEASUREMENT, "Start recording foreground host") { - val config = MeasurementConfig.from(intent) + val config = MeasurementConfig.from(intent, epochClock) require(config.sessionId.isNotBlank()) { "Recording session ID is missing" } activeConfig = config isFinishing = false promoteToForeground(config) configureRuntimeResources(config) - val serviceController = ServiceController(this, config, serviceScope) + val serviceController = ServiceController( + context = this, + config = config, + scope = serviceScope, + storage = storageHandler, + diagnosticLogger = diagnosticLogger, + clock = epochClock, + ) controller = serviceController observeEngine(serviceController) serviceScope.launch { diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt index fe3b753..bd72681 100644 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/motionapps/sensorservices/types/SensorHolder.kt @@ -6,9 +6,12 @@ import android.hardware.SensorEventListener 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.appResult import com.motionapps.sensorbox.core.error.combineAppResults import com.motionapps.sensorbox.core.error.suspendAppResult +import com.motionapps.sensorbox.core.error.toDiagnosticEvent +import com.motionapps.sensorbox.core.time.EpochClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -18,45 +21,57 @@ import kotlinx.coroutines.channels.Channel import java.io.IOException import java.io.OutputStream -class SensorHolder(val spec: SensorSpec, outputStream: OutputStream) : SensorEventListener { +class SensorHolder( + val spec: SensorSpec, + outputStream: OutputStream, + private val diagnosticLogger: DiagnosticLogger, + private val clock: EpochClock, +) : SensorEventListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val samples = Channel(capacity = Channel.BUFFERED) private val writer = outputStream.bufferedWriter() private val writerJob = scope.async { try { writer.append(spec.header) - for (sample in samples) writer.appendLine(sample.toCsv(spec.axisCount)) + writer.flush() + for (sample in samples) { + writer.appendLine(sample.toCsv(spec.axisCount)) + writer.flush() + } } catch (error: IOException) { - writerFailure = AppError.from(AppErrorCode.STORAGE, "Write ${spec.fileName}", error) - samples.close() + throw reportWriterFailure(error) + } catch (error: IllegalStateException) { + throw reportWriterFailure(error) } } - @Volatile - private var writerFailure: AppError? = null - override fun onSensorChanged(event: SensorEvent) { - if (writerFailure != null) return val result = samples.trySend( SensorSample( sensorTimestampNanos = event.timestamp, - unixTimestampMillis = System.currentTimeMillis(), + unixTimestampMillis = clock.nowMillis(), values = event.values.copyOf(spec.axisCount), accuracy = event.accuracy, ), ) - if (result.isFailure && writerFailure == null) { - writerFailure = AppError(AppErrorCode.STORAGE, "Buffer ${spec.fileName}") + if (result.isFailure && result.exceptionOrNull() == null) { + samples.close(IllegalStateException("Sensor sample buffer is full")) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + private fun reportWriterFailure(error: T): T { + val appError = AppError.from(AppErrorCode.STORAGE, "Write ${spec.fileName}", error) + diagnosticLogger.record(appError.toDiagnosticEvent()) + samples.close(error) + return error + } + suspend fun close(): AppResult { samples.close() val results = mutableListOf>() results += suspendAppResult(AppErrorCode.STORAGE, "Finish ${spec.fileName} writer") { writerJob.await() } - writerFailure?.let { results += AppResult.failure(it) } results += appResult(AppErrorCode.STORAGE, "Flush ${spec.fileName}") { writer.flush() } results += appResult(AppErrorCode.STORAGE, "Close ${spec.fileName}") { writer.close() } scope.cancel() diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/StorageHandlerTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/StorageHandlerTest.kt new file mode 100644 index 0000000..e16f308 --- /dev/null +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/StorageHandlerTest.kt @@ -0,0 +1,70 @@ +package com.motionapps.sensorservices.handlers + +import android.content.ContextWrapper +import android.content.Intent +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.storage.DocumentStorage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream + +class StorageHandlerTest { + @Test + fun `Given configured storage When session directory and file are requested Then production adapter delegates`() { + val documents = FakeDocumentStorage() + val storage: MeasurementStorage = StorageHandler(ContextWrapper(null), documents) + + val directory = storage.createMeasurementDirectory("session", useInternalStorage = false) + val file = storage.openMeasurementFile( + folderName = "session", + mimeType = "text/csv", + fileName = "sensor.csv", + useInternalStorage = false, + ) + + assertTrue(directory.isSuccess) + assertSame(documents.output, file.getOrNull()) + assertEquals("session", documents.createdMeasurement) + assertEquals("sensor.csv", documents.openedFile) + } + + private class FakeDocumentStorage : DocumentStorage { + val output = ByteArrayOutputStream() + var createdMeasurement: String? = null + var openedFile: String? = null + + override fun persistRootAccess(intent: Intent): AppResult = AppResult.success(Unit) + + override fun hasConfiguredDirectory(): AppResult = AppResult.success(true) + + override fun displayPath(): AppResult = AppResult.success("fixture") + + override fun createMeasurementDirectory(measurementName: String): AppResult { + createdMeasurement = measurementName + return AppResult.success(Unit) + } + + override fun openMeasurementFile( + measurementName: String, + mimeType: String, + fileName: String, + replaceExisting: Boolean, + ): AppResult { + openedFile = fileName + return AppResult.success(output) + } + + override fun deleteMeasurement(measurementName: String): AppResult = AppResult.success(Unit) + + override fun copyToMeasurement( + input: InputStream, + measurementName: String, + fileName: String, + mimeType: String, + ): AppResult = AppResult.success(Unit) + } +} diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt new file mode 100644 index 0000000..476c36b --- /dev/null +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt @@ -0,0 +1,108 @@ +package com.motionapps.sensorservices.handlers.measurements + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorservices.handlers.MeasurementStorage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.OutputStream + +class ActivityRecognitionMeasurementTest { + @Test + fun `Given fake storage and platform When activity measurement runs Then lifecycle has no Android types`() { + val storage = FakeMeasurementStorage() + val platform = FakeActivityRecognitionPlatform() + val measurement = ActivityRecognitionMeasurement(15, storage, platform) + + assertTrue(measurement.prepare("session", useInternalStorage = true).isSuccess) + assertTrue(measurement.start().isSuccess) + platform.emitUpdate(ActivityUpdate(123L, listOf(1, 2, 3, 4, 5, 6, 7, 8))) + platform.emitTransitions(listOf(ActivityTransitionSample(456L, 2, 1))) + val stopped = kotlinx.coroutines.runBlocking { measurement.stop() } + + assertTrue(stopped.isSuccess) + assertEquals(15, platform.startedPeriodSeconds) + assertEquals(1, platform.stopCalls) + assertTrue(storage.text("activity_updates.csv").contains("123;1;2;3;4;5;6;7;8")) + assertTrue(storage.text("activity_transitions.csv").contains("456;2;1")) + } + + @Test + fun `Given transition storage failure When prepared Then platform initialization does not run`() { + val storage = FakeMeasurementStorage(failOnCall = 2) + val platform = FakeActivityRecognitionPlatform() + val measurement = ActivityRecognitionMeasurement(15, storage, platform) + + val result = measurement.prepare("session", useInternalStorage = false) + + assertTrue(result.isFailure) + assertEquals(0, platform.prepareCalls) + assertEquals(2, storage.openCalls) + } + + private class FakeMeasurementStorage(private val failOnCall: Int? = null) : MeasurementStorage { + private val outputs = mutableMapOf() + var openCalls = 0 + private set + + override fun createMeasurementDirectory(folderName: String, useInternalStorage: Boolean): AppResult = + AppResult.success(Unit) + + override fun openMeasurementFile( + folderName: String, + mimeType: String, + fileName: String, + useInternalStorage: Boolean, + ): AppResult { + openCalls += 1 + if (openCalls == failOnCall) { + return AppResult.failure(AppError(AppErrorCode.STORAGE, "Open fake file")) + } + return AppResult.success(ByteArrayOutputStream().also { outputs[fileName] = it }) + } + + fun text(fileName: String): String = outputs.getValue(fileName).toString(Charsets.UTF_8.name()) + } + + private class FakeActivityRecognitionPlatform : ActivityRecognitionPlatform { + var prepareCalls = 0 + private set + var stopCalls = 0 + private set + var startedPeriodSeconds: Int? = null + private set + private var updateCallback: ((ActivityUpdate) -> Unit)? = null + private var transitionCallback: ((List) -> Unit)? = null + + override fun prepare( + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult { + prepareCalls += 1 + updateCallback = onUpdate + transitionCallback = onTransitions + return AppResult.success(Unit) + } + + override fun start(periodSeconds: Int): AppResult { + startedPeriodSeconds = periodSeconds + return AppResult.success(Unit) + } + + override fun stop(): AppResult { + stopCalls += 1 + return AppResult.success(Unit) + } + + fun emitUpdate(update: ActivityUpdate) { + checkNotNull(updateCallback).invoke(update) + } + + fun emitTransitions(transitions: List) { + checkNotNull(transitionCallback).invoke(transitions) + } + } +} diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt new file mode 100644 index 0000000..6ea302f --- /dev/null +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt @@ -0,0 +1,64 @@ +package com.motionapps.sensorservices.handlers.measurements + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.float +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import org.junit.Assert.assertEquals +import org.junit.Test + +class MeasurementMetadataSerializationTest { + @Test + fun `Given measurement metadata When serialized Then historical keys and value types are preserved`() { + val metadata = MeasurementMetadata( + millis = 100L, + nanos = 200L, + type = "TIMED", + date = "23. 08. 2026 12:00:00", + folder = "fixture", + notes = listOf("note"), + annotations = listOf(MeasurementAnnotation(timestamp = 300L, annotation = "mark")), + ranges = listOf(SensorRange(sensor = "Accelerometer", type = 1, range = 9.81f)), + alarms = listOf(400L), + configuredAlarmOffsetsSeconds = listOf(5), + durationMillis = 60_000L, + activityRecognition = true, + significantMotion = false, + ) + + val json = Json.parseToJsonElement(Json.encodeToString(metadata)).jsonObject + + assertEquals( + setOf( + "millis", + "nanos", + "type", + "date", + "folder", + "notes", + "annotations", + "ranges", + "alarms", + "configuredAlarmOffsetsSeconds", + "durationMillis", + "activityRecognition", + "significantMotion", + ), + json.keys, + ) + assertEquals(100L, json.getValue("millis").jsonPrimitive.long) + assertEquals("note", json.getValue("notes").jsonArray.single().jsonPrimitive.content) + val annotation = json.getValue("annotations").jsonArray.single().jsonObject + val range = json.getValue("ranges").jsonArray.single().jsonObject + assertEquals(300L, annotation.getValue("timestamp").jsonPrimitive.long) + assertEquals("mark", annotation.getValue("annotation").jsonPrimitive.content) + assertEquals(1, range.getValue("type").jsonPrimitive.int) + assertEquals(9.81f, range.getValue("range").jsonPrimitive.float) + assertEquals(true, json.getValue("activityRecognition").jsonPrimitive.boolean) + } +} diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementPreparationTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementPreparationTest.kt new file mode 100644 index 0000000..801383c --- /dev/null +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/handlers/measurements/MeasurementPreparationTest.kt @@ -0,0 +1,66 @@ +package com.motionapps.sensorservices.handlers.measurements + +import android.hardware.Sensor +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.time.EpochClock +import com.motionapps.sensorservices.handlers.GPSHandler +import com.motionapps.sensorservices.handlers.MeasurementStorage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.OutputStream + +class MeasurementPreparationTest { + @Test + fun `Given first sensor file failure When sensors prepare Then later sensor files are not opened`() { + val storage = FailingMeasurementStorage() + val measurement = SensorMeasurement(storage, DiagnosticLogger { }, EpochClock { 1L }) + + val result = measurement.prepare( + folderName = "session", + useInternalStorage = true, + sensorTypes = setOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE), + samplingPeriod = 0, + ) + + assertTrue(result.isFailure) + assertEquals(1, storage.openCalls) + } + + @Test + fun `Given GPS file failure When GPS prepares Then no output work follows`() { + val storage = FailingMeasurementStorage() + val measurement = GPSMeasurement(GPSHandler(), storage, EpochClock { 1L }) + + val result = measurement.prepare( + folderName = "session", + useInternalStorage = true, + intervalSeconds = 10, + minimumDistanceMeters = 20, + ) + + assertTrue(result.isFailure) + assertEquals(1, storage.openCalls) + } + + private class FailingMeasurementStorage : MeasurementStorage { + var openCalls = 0 + private set + + override fun createMeasurementDirectory(folderName: String, useInternalStorage: Boolean): AppResult = + AppResult.success(Unit) + + override fun openMeasurementFile( + folderName: String, + mimeType: String, + fileName: String, + useInternalStorage: Boolean, + ): AppResult { + openCalls += 1 + return AppResult.failure(AppError(AppErrorCode.STORAGE, "Open fake file")) + } + } +} diff --git a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt index 2d2d40c..3d1f22a 100644 --- a/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/motionapps/sensorservices/types/SensorHolderErrorTest.kt @@ -3,6 +3,9 @@ package com.motionapps.sensorservices.types import android.hardware.Sensor import com.motionapps.sensorbox.core.error.AppError import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.DiagnosticEvent +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.core.time.EpochClock import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -14,16 +17,26 @@ class SensorHolderErrorTest { @Test fun `Given a failed output stream When holder closes Then storage AppError is returned`() = runBlocking { val spec = checkNotNull(SensorSpec.fromType(Sensor.TYPE_ACCELEROMETER)) - val holder = SensorHolder(spec, FailingOutputStream()) + val logger = RecordingDiagnosticLogger() + val holder = SensorHolder(spec, FailingOutputStream(), logger, EpochClock { 123L }) val result = holder.close() val error = result.errorOrNull() assertTrue(error is AppError) assertEquals(AppErrorCode.STORAGE, (error as AppError).code) + assertEquals("Write ${spec.fileName}", logger.events.single().operation) } private class FailingOutputStream : OutputStream() { override fun write(value: Int): Unit = throw IOException("disk full") } + + private class RecordingDiagnosticLogger : DiagnosticLogger { + val events = mutableListOf() + + override fun record(event: DiagnosticEvent) { + events += event + } + } } diff --git a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt index 6e96a73..2afbe68 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt @@ -50,8 +50,8 @@ class MainActivity : ComponentActivity() { setContent { val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(viewModel) { viewModel.effects.collect(::handleEffect) } - LaunchedEffect(state.preferences.keepWearDisplayOn) { - updateDisplayPolicy(state.preferences.keepWearDisplayOn) + LaunchedEffect(state.preferences.display.keepWearDisplayOn) { + updateDisplayPolicy(state.preferences.display.keepWearDisplayOn) } WearSensorBoxTheme { WearDashboardScreen( diff --git a/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt index 5021b69..b37f27a 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/di/DiagnosticsModule.kt @@ -4,10 +4,12 @@ import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build +import com.motionapps.sensorbox.core.error.CompositeDiagnosticLogger 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 com.motionapps.sensorbox.core.error.LogcatDiagnosticLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -40,7 +42,8 @@ object DiagnosticsModule { ) @Provides - fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = diagnostics + fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = + CompositeDiagnosticLogger(diagnostics, LogcatDiagnosticLogger()) @Provides fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics diff --git a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt index ba05083..a022811 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/domain/measurement/WearMeasurementControlUseCase.kt @@ -8,6 +8,7 @@ import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.AppResult import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.time.EpochClock import com.motionapps.sensorservices.intent.MeasurementIntentFactory import com.motionapps.sensorservices.intent.MeasurementLaunchRequest import com.motionapps.sensorservices.services.MeasurementService @@ -30,6 +31,7 @@ interface WearRecordingController { class WearMeasurementControlUseCase @Inject constructor( @ApplicationContext private val context: Context, private val intentFactory: MeasurementIntentFactory, + private val clock: EpochClock, ) : WearRecordingController { override fun start( sessionId: String, @@ -53,7 +55,7 @@ class WearMeasurementControlUseCase @Inject constructor( preferences: AppPreferences, sessionId: String = java.util.UUID.randomUUID().toString(), folderName: String = intentFactory.newFolderName(), - startAtEpochMillis: Long = System.currentTimeMillis(), + startAtEpochMillis: Long = clock.nowMillis(), durationMillis: Long = 0L, measurementType: String = "ENDLESS", ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Request Wear measurement start") { @@ -62,12 +64,12 @@ class WearMeasurementControlUseCase @Inject constructor( folderName = folderName, useInternalStorage = true, sensorIds = sensorIds, - sensorSamplingPeriod = samplingPeriod(preferences.sensorSamplingPeriod), + sensorSamplingPeriod = samplingPeriod(preferences.recording.sensorSamplingPeriod), includesGps = includesGps, - stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, - useWakeLock = preferences.useWakeLock, - gpsIntervalSeconds = preferences.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, + stopOnLowBattery = preferences.recording.restrictMeasurementOnLowBattery, + useWakeLock = preferences.recording.useWakeLock, + gpsIntervalSeconds = preferences.recording.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.recording.gpsMinDistanceMeters, measurementType = measurementType, startAtEpochMillis = startAtEpochMillis, durationMillis = durationMillis, diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt index 9bc2e0b..b73e00f 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardScreen.kt @@ -48,7 +48,7 @@ fun WearDashboardScreen( WearRoute.RECORD -> WearRecordScreen(state, accept) WearRoute.LIVE -> WearLiveScreen(state, chartModelProducer, accept) WearRoute.SETTINGS -> WearSettingsScreen(state, accept) - WearRoute.ACTIVE -> WearActiveScreen(state.preferences.keepWearDisplayOn, accept) + WearRoute.ACTIVE -> WearActiveScreen(state.preferences.display.keepWearDisplayOn, accept) } } @@ -178,7 +178,11 @@ private fun TransformingLazyColumnScope.preferenceItems( R.string.sampling_normal, ).forEachIndexed { index, label -> item { - WearChoiceButton(stringResource(label), state.preferences.sensorSamplingPeriod == index, transformation) { + WearChoiceButton( + stringResource(label), + state.preferences.recording.sensorSamplingPeriod == index, + transformation, + ) { accept(WearDashboardIntent.SetSamplingPeriod(index)) } } @@ -186,21 +190,21 @@ private fun TransformingLazyColumnScope.preferenceItems( item { WearChoiceButton( stringResource(R.string.stop_on_low_battery), - state.preferences.restrictMeasurementOnLowBattery, + state.preferences.recording.restrictMeasurementOnLowBattery, transformation, ) { accept(WearDashboardIntent.ToggleBatteryRestriction) } } item { - WearChoiceButton(stringResource(R.string.wake_lock), state.preferences.useWakeLock, transformation) { + WearChoiceButton(stringResource(R.string.wake_lock), state.preferences.recording.useWakeLock, transformation) { accept(WearDashboardIntent.ToggleWakeLock) } } item { WearChoiceButton( stringResource(R.string.keep_display_on), - state.preferences.keepWearDisplayOn, + state.preferences.display.keepWearDisplayOn, transformation, ) { accept(WearDashboardIntent.ToggleDisplay) diff --git a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt index a7198ec..3d4bf04 100644 --- a/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardViewModel.kt @@ -163,16 +163,16 @@ class WearDashboardViewModel @Inject constructor( private fun toggleBatteryRestriction() = updatePreference( AppPreferencesIntent.SetLowBatteryRestriction( - !mutableState.value.preferences.restrictMeasurementOnLowBattery, + !mutableState.value.preferences.recording.restrictMeasurementOnLowBattery, ), ) private fun toggleWakeLock() = updatePreference( - AppPreferencesIntent.SetWakeLock(!mutableState.value.preferences.useWakeLock), + AppPreferencesIntent.SetWakeLock(!mutableState.value.preferences.recording.useWakeLock), ) private fun toggleDisplay() = updatePreference( - AppPreferencesIntent.SetKeepWearDisplayOn(!mutableState.value.preferences.keepWearDisplayOn), + AppPreferencesIntent.SetKeepWearDisplayOn(!mutableState.value.preferences.display.keepWearDisplayOn), ) private fun startSync() { diff --git a/wear/src/test/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt b/wear/src/test/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt index 9209132..903ff8a 100644 --- a/wear/src/test/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt +++ b/wear/src/test/java/com/motionapps/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt @@ -28,7 +28,7 @@ class WearDashboardReducerTest { val actual = WearDashboardReducer.reduce(givenState, WearDashboardIntent.ToggleSensor(21)) assertTrue(21 in actual.selectedSensorIds) - assertEquals(30, actual.preferences.gpsIntervalSeconds) + assertEquals(30, actual.preferences.recording.gpsIntervalSeconds) } @Test