From f42f42fe4dbd073538522b4a2be7be9f4410ae78 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Fri, 28 Aug 2026 23:19:13 +0200 Subject: [PATCH] fix: make recording shutdown and sensor writes reliable --- .../files/WearSyncEmulatorFixture.kt | 2 +- .../emulator/RecordingEmulatorFixture.kt | 14 +- .../AndroidMeasurementRepository.kt | 67 ++++++++-- .../paired/PhoneRecordingSessionObserver.kt | 8 +- .../presentation/main/SensorDetailsScreen.kt | 3 +- .../sensorbox/recording/RecordingEngine.kt | 124 ++++++++++++------ .../sensorbox/recording/RecordingModel.kt | 24 ++-- .../sensorbox/recording/RecordingSource.kt | 15 +-- .../recording/RecordingEngineTest.kt | 101 +++++++++++--- sensorservices/build.gradle.kts | 1 + .../sensorservices/handlers/GPSHandler.kt | 118 +++++------------ .../ActivityRecognitionMeasurement.kt | 30 +++-- .../ActivityRecognitionPlatform.kt | 76 +++++++---- .../handlers/measurements/ExtraInfoHandler.kt | 83 ++++++++++-- .../handlers/measurements/GPSMeasurement.kt | 92 ++++--------- .../measurements/SensorMeasurement.kt | 41 ++++-- .../measurements/SignificantMotion.kt | 47 ++++--- .../intent/MeasurementLaunchRequest.kt | 40 +++++- .../ActivityRecordingSource.kt | 22 ++-- .../AndroidRecordingSources.kt | 28 ++-- .../serviceController/GpsRecordingSource.kt | 20 +-- .../serviceController/MeasurementConfig.kt | 52 -------- .../SensorRecordingSource.kt | 16 ++- .../serviceController/ServiceController.kt | 20 ++- .../ServiceControllerFactory.kt | 5 +- .../serviceController/SessionArtifacts.kt | 52 -------- .../SessionRecordingSource.kt | 63 ++++++++- .../SignificantMotionRecordingSource.kt | 10 +- .../services/MeasurementHostResources.kt | 16 +-- .../services/MeasurementService.kt | 51 ++++--- .../sensorservices/services/Notify.kt | 12 ++ .../session/MeasurementSessionStore.kt | 11 +- .../sensorservices/types/SensorHolder.kt | 115 ++++++++++------ .../sensorservices/types/SensorSpec.kt | 28 ++-- .../ActivityRecognitionMeasurementTest.kt | 68 +++++----- .../MeasurementMetadataSerializationTest.kt | 76 ++++++++--- .../MeasurementPreparationTest.kt | 10 +- .../types/SensorHolderErrorTest.kt | 41 +++++- .../sensorservices/types/SensorSpecTest.kt | 2 +- .../emulator/WearRecordingEmulatorFixture.kt | 12 +- .../WearRecordingSessionObserver.kt | 8 +- 41 files changed, 960 insertions(+), 664 deletions(-) delete mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt delete mode 100644 sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt diff --git a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt index 98bcfbc..49e7a31 100644 --- a/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt +++ b/WearOsLib/src/testFixtures/java/com/tomasrepcik/sensorbox/wearoslib/files/WearSyncEmulatorFixture.kt @@ -34,7 +34,7 @@ object WearSyncEmulatorFixture { textFile( measurement = "EMULATOR_SINGLE_CSV", name = "accelerometer.csv", - text = "t_sensor;t_unix;x;y;z;accuracy\n123;456;1.0;2.0;3.0;3\n", + text = "t_sensor;x;y;z;accuracy\n123;1.0;2.0;3.0;3\n", ), ), ), diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt index 0269a55..07c7ea2 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/RecordingEmulatorFixture.kt @@ -23,8 +23,8 @@ import java.io.File internal data class RecordedSensor( val type: Int, val fileName: String, - val header: String = "t_sensor;t_unix;x;y;z;accuracy", - val columnCount: Int = 6, + val header: String = "t_sensor;x;y;z;accuracy", + val columnCount: Int = 5, ) internal data class RecordingScenario( @@ -345,6 +345,8 @@ internal class RecordingEmulatorFixture(private val context: Context) { private fun assertSessionMetadata() { assertEquals(scenario.name, metadata.getString("folder")) + assertTrue(metadata.getLong("millis") >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS) + assertTrue(metadata.getLong("nanos") > 0L) assertEquals(scenario.notes, metadata.getJSONArray("notes").toStringList()) assertEquals(scenario.durationMillis.coerceAtLeast(0), metadata.getLong("durationMillis")) assertEquals( @@ -408,13 +410,7 @@ internal class RecordingEmulatorFixture(private val context: Context) { columns } val sensorTimestamps = samples.map { it[0].toLong() } - val unixTimestamps = samples.map { it[1].toLong() } assertEquals(sensorTimestamps.sorted(), sensorTimestamps) - assertEquals(unixTimestamps.sorted(), unixTimestamps) - assertTrue( - "A sample was written before recording started: $unixTimestamps", - unixTimestamps.all { it >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS }, - ) } } @@ -434,7 +430,7 @@ internal class RecordingEmulatorFixture(private val context: Context) { const val ACTIVITY_TRANSITIONS_FILE = "activity_transitions.csv" const val ACTIVITY_TRANSITIONS_HEADER = "t_nanos;activity;enter_exit" const val SIGNIFICANT_MOTION_FILE = "significant_motion.csv" - const val SIGNIFICANT_HEADER = "t_unix;event" + const val SIGNIFICANT_HEADER = "t_sensor;event" const val MINIMUM_ROWS = 2 const val TIMESTAMP_TOLERANCE_MILLIS = 250L const val FILE_WAIT_ATTEMPTS = 40 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt index 7db8c67..fc2dab6 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt @@ -62,12 +62,16 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva override suspend fun loadMeasurementFile(measurementId: String, fileId: String): AppResult = withContext(Dispatchers.IO) { suspendAppResult(AppErrorCode.STORAGE, "Read measurement file") { - val document = measurementDirectory(measurementId).findFile(fileId) + val directory = measurementDirectory(measurementId) + val document = directory.findFile(fileId) ?.takeIf(DocumentFile::isFile) ?: error("Measurement file is unavailable") when { document.name.equals(GPS_FILE, ignoreCase = true) -> parseGpsCoordinates(document) - document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> parseSensorSeries(document) + + document.name.orEmpty().endsWith(CSV_EXTENSION, ignoreCase = true) -> + parseSensorSeries(document, readSensorTimeAnchor(directory)) + else -> parseTextFile(document) } } @@ -88,11 +92,7 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva ?: throw IllegalArgumentException("Measurement does not exist") private fun createMeasurementSummary(directory: DocumentFile): MeasurementSummary { - val metadata = directory.findFile(METADATA_FILE) - ?.takeIf(DocumentFile::isFile) - ?.let(::readDocumentText) - .orEmpty() - val json = metadata.takeIf(String::isNotBlank)?.let(::parseMetadataObject) + val json = readMetadata(directory) return MeasurementSummary( id = checkNotNull(directory.name), name = directory.name.orEmpty(), @@ -137,7 +137,10 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva } } - private fun parseSensorSeries(document: DocumentFile): MeasurementFileContent.SensorSeries { + private fun parseSensorSeries( + document: DocumentFile, + anchor: SensorTimeAnchor?, + ): MeasurementFileContent.SensorSeries { var columns = emptyList() val samples = mutableListOf() var totalSamples = 0 @@ -145,13 +148,21 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva val iterator = lines.iterator() if (!iterator.hasNext()) return@useLines val header = iterator.next().split(DELIMITER) - val timestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } ?: 0 + val unixTimestampIndex = header.indexOf("t_unix").takeIf { it >= 0 } + val sensorTimestampIndex = header.indexOf("t_sensor").takeIf { it >= 0 } + val timestampIndex = unixTimestampIndex ?: sensorTimestampIndex ?: 0 val valueIndexes = header.indices.filter { index -> index != timestampIndex && header[index] !in NON_VALUE_COLUMNS } columns = valueIndexes.map(header::get) while (iterator.hasNext()) { - parseSensorSample(iterator.next(), timestampIndex, valueIndexes)?.let { sample -> + parseSensorSample( + line = iterator.next(), + timestampIndex = timestampIndex, + valueIndexes = valueIndexes, + sensorTimestamp = sensorTimestampIndex != null, + anchor = anchor, + )?.let { sample -> totalSamples += 1 retainBounded(samples, sample, totalSamples, MAX_CHART_SAMPLES) } @@ -160,13 +171,42 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva return MeasurementFileContent.SensorSeries(columns, samples, totalSamples > samples.size) } - private fun parseSensorSample(line: String, timestampIndex: Int, valueIndexes: List): SensorSeriesSample? { + private fun parseSensorSample( + line: String, + timestampIndex: Int, + valueIndexes: List, + sensorTimestamp: Boolean, + anchor: SensorTimeAnchor?, + ): SensorSeriesSample? { val fields = line.split(DELIMITER) - val timestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null + val rawTimestamp = fields.getOrNull(timestampIndex)?.toLongOrNull() ?: return null + val timestamp = if (sensorTimestamp) { + anchor?.unixMillis?.plus((rawTimestamp - anchor.elapsedRealtimeNanos) / NANOS_PER_MILLISECOND) + ?: rawTimestamp / NANOS_PER_MILLISECOND + } else { + rawTimestamp + } val values = valueIndexes.mapNotNull { fields.getOrNull(it)?.toDoubleOrNull() } return values.takeIf { it.size == valueIndexes.size }?.let { SensorSeriesSample(timestamp, it) } } + private fun readSensorTimeAnchor(directory: DocumentFile): SensorTimeAnchor? { + val json = readMetadata(directory) + val unixMillis = json?.get("millis")?.asPrimitive()?.longOrNull + val elapsedRealtimeNanos = json?.get("nanos")?.asPrimitive()?.longOrNull + return if (unixMillis != null && elapsedRealtimeNanos != null) { + SensorTimeAnchor(unixMillis, elapsedRealtimeNanos) + } else { + null + } + } + + private fun readMetadata(directory: DocumentFile): JsonObject? = directory.findFile(METADATA_FILE) + ?.takeIf(DocumentFile::isFile) + ?.let(::readDocumentText) + ?.takeIf(String::isNotBlank) + ?.let(::parseMetadataObject) + private fun parseGpsCoordinates(document: DocumentFile): MeasurementFileContent.GpsCoordinates { val coordinates = mutableListOf() var totalCoordinates = 0 @@ -245,7 +285,10 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva const val MAX_TEXT_CHARACTERS = 100_000 const val TEXT_BUFFER_SIZE = 4_096 const val SAMPLE_REPLACEMENT_INTERVAL = 100 + const val NANOS_PER_MILLISECOND = 1_000_000L val NON_VALUE_COLUMNS = setOf("t_sensor", "accuracy", "provider") val JSON = Json { ignoreUnknownKeys = true } } + + private data class SensorTimeAnchor(val unixMillis: Long, val elapsedRealtimeNanos: Long) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt index 50e6dfc..baa3460 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt @@ -2,9 +2,9 @@ package com.tomasrepcik.sensorbox.domain.paired import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionEvent import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopReason +import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopped import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,13 +27,11 @@ class PhoneRecordingSessionObserver @Inject constructor( fun start() { if (!started.compareAndSet(false, true)) return scope.launch { - sessionStore.events.collect { event -> - if (event is MeasurementSessionEvent.Stopped) onStopped(event) - } + sessionStore.events.collect(::onStopped) } } - private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) { + private suspend fun onStopped(event: MeasurementStopped) { event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } val reason = event.reason.toAutomaticWearReason() ?: return pairedRecordingCoordinator.onAutomaticPhoneStop(event.sessionId, reason) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt index b863025..2f4c465 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -261,12 +261,13 @@ internal fun rememberGpsDetails( DisposableEffect(gpsHandler, hasPermission, intervalSeconds, minimumDistanceMeters) { var active = true if (hasPermission) { - gpsHandler.configure(intervalSeconds, minimumDistanceMeters) gpsHandler.addCallback( GpsDetailsCallback( onLocation = { if (active && it != null) location = it }, onAvailability = { if (active) isAvailable = it }, ), + intervalSeconds = intervalSeconds, + minDistanceMeters = minimumDistanceMeters, ) } onDispose { diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt index 702aa18..54fe328 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngine.kt @@ -6,24 +6,24 @@ import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch class RecordingEngine( sources: List, private val scope: CoroutineScope, - private val clock: RecordingClock, - private val delay: RecordingDelay, + private val waitFor: suspend (Long) -> Unit = { delay(it) }, ) { private val sourceByType = sources.associateBy(RecordingSource::type) private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) private var activeRecording: ActiveRecording? = null - private var durationStop: Job? = null - private var lastStoppedSessionId: RecordingSessionId? = null - private var lastStopResult: AppResult = AppResult.success(Unit) + private var lastStopResult: AppResult? = null val events: SharedFlow = mutableEvents.asSharedFlow() @@ -32,9 +32,10 @@ class RecordingEngine( if (activeRecording != null) return conflict("Start recording", plan.sessionId) val validation = validate(plan) - if (validation is AppResult.Failure) return rejectStart(plan.sessionId, validation.error) + if (validation is AppResult.Failure) return validation val recording = ActiveRecording(plan) + lastStopResult = null activeRecording = recording for (spec in plan.sources.sortedBy { it.type.ordinal }) { @@ -46,45 +47,78 @@ class RecordingEngine( if (activeRecording !== recording) return AppResult.success(Unit) } - val startedAt = clock.epochMillis() - mutableEvents.tryEmit(RecordingEvent.RecordingStarted(plan.sessionId, startedAt)) - scheduleDurationStop(plan) + mutableEvents.tryEmit(RecordingEvent.RecordingStarted(plan.sessionId)) + scheduleDurationStop(recording) + observeSourceFailures(recording) return AppResult.success(Unit) } - suspend fun stop(sessionId: RecordingSessionId, reason: RecordingStopReason): AppResult { - val recording = activeRecording - if (recording == null) { - return if (lastStoppedSessionId == sessionId) lastStopResult else conflict("Stop recording", sessionId) - } - if (recording.plan.sessionId != sessionId) return conflict("Stop recording", sessionId) + suspend fun stop(reason: RecordingStopReason): AppResult { + val recording = activeRecording ?: return lastStopResult ?: conflict("Stop recording") + return stopRecording(recording, RecordingStopContext(reason)) + } + private suspend fun stopRecording(recording: ActiveRecording, context: RecordingStopContext): AppResult { activeRecording = null - durationStop?.cancel() - durationStop = null + recording.durationStop?.cancel() + recording.failureMonitor?.cancel() - val stoppedSources = stopSources(recording.startedSources) - val result = stoppedSources.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") - lastStoppedSessionId = sessionId + val stoppedSources = stopSources(recording.startedSources, context) + val results = buildList { + context.failures.forEach { add(AppResult.failure(it)) } + addAll(stoppedSources) + } + val result = results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") lastStopResult = result - mutableEvents.tryEmit(RecordingEvent.RecordingStopped(sessionId, reason, result)) + mutableEvents.tryEmit( + RecordingEvent.RecordingStopped( + sessionId = recording.plan.sessionId, + reason = context.reason, + result = result, + ), + ) return result } private suspend fun stopFailedStart(recording: ActiveRecording, startError: AppError): AppResult { - if (activeRecording === recording) activeRecording = null - val stoppedSources = stopSources(recording.startedSources) + if (activeRecording !== recording) return lastStopResult ?: AppResult.failure(startError) + activeRecording = null + val stoppedSources = stopSources( + recording.startedSources, + RecordingStopContext(RecordingStopReason.SOURCE_FAILURE, listOf(startError)), + ) val result = (listOf(AppResult.failure(startError)) + stoppedSources) .combineAppResults(AppErrorCode.MEASUREMENT, "Stop failed recording start") - return rejectStart(recording.plan.sessionId, checkNotNull(result.errorOrNull())) + return result } - private suspend fun stopSources(sources: List): List> = - sources.asReversed().map { source -> source.stop() } + private suspend fun stopSources( + sources: List, + context: RecordingStopContext, + ): List> { + var currentContext = context + val results = mutableListOf>() + for (source in sources.asReversed()) { + val result = source.stop(currentContext) + results += result + result.errorOrNull()?.let { failure -> currentContext = currentContext.withFailure(failure) } + } + return results + } - private fun rejectStart(sessionId: RecordingSessionId, error: AppError): AppResult { - mutableEvents.tryEmit(RecordingEvent.RecordingStartRejected(sessionId, error)) - return AppResult.failure(error) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private fun observeSourceFailures(recording: ActiveRecording) { + val failures = recording.startedSources.map(RecordingSource::failures) + recording.failureMonitor = scope.launch { + val failure = merge(*failures.toTypedArray()).first() + if (activeRecording === recording) { + recording.failureMonitor = null + stopRecording( + recording = recording, + context = RecordingStopContext(RecordingStopReason.SOURCE_FAILURE, listOf(failure)), + ) + } + } } private fun validate(plan: RecordingPlan): AppResult { @@ -108,26 +142,30 @@ class RecordingEngine( } } - private fun scheduleDurationStop(plan: RecordingPlan) { + private fun scheduleDurationStop(recording: ActiveRecording) { + val plan = recording.plan if (plan.durationMillis <= 0L) return - durationStop = scope.launch { - delay.pause(plan.durationMillis) - stop(plan.sessionId, RecordingStopReason.DURATION_EXPIRED) + recording.durationStop = scope.launch { + waitFor(plan.durationMillis) + stop(RecordingStopReason.DURATION_EXPIRED) } } - private fun conflict(operation: String, sessionId: RecordingSessionId): AppResult = AppResult.failure( - AppError( - code = AppErrorCode.CONFLICT, - operation = operation, - diagnosticMessage = "$operation conflicts with the active recording", - context = mapOf("sessionId" to sessionId.value), - ), - ) - - private data class ActiveRecording( + private fun conflict(operation: String, sessionId: RecordingSessionId? = null): AppResult = + AppResult.failure( + AppError( + code = AppErrorCode.CONFLICT, + operation = operation, + diagnosticMessage = "$operation conflicts with the current recording state", + context = sessionId?.let { mapOf("sessionId" to it.value) }.orEmpty(), + ), + ) + + private class ActiveRecording( val plan: RecordingPlan, val startedSources: MutableList = mutableListOf(), + var durationStop: Job? = null, + var failureMonitor: Job? = null, ) private companion object { diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt index a81d89f..2373e1f 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingModel.kt @@ -1,16 +1,13 @@ package com.tomasrepcik.sensorbox.recording -import java.util.UUID +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppResult @JvmInline value class RecordingSessionId(val value: String) { init { require(value.isNotBlank()) } - - companion object { - fun create(): RecordingSessionId = RecordingSessionId(UUID.randomUUID().toString()) - } } enum class RecordingSourceType { @@ -59,20 +56,21 @@ enum class RecordingStopReason { PLATFORM_DESTROYED, } +data class RecordingStopContext(val reason: RecordingStopReason, val failures: List = emptyList()) { + val failure: AppError? + get() = failures.firstOrNull() + + fun withFailure(failure: AppError): RecordingStopContext = copy(failures = failures + failure) +} + sealed interface RecordingEvent { val sessionId: RecordingSessionId - data class RecordingStarted(override val sessionId: RecordingSessionId, val startedAtEpochMillis: Long) : - RecordingEvent - - data class RecordingStartRejected( - override val sessionId: RecordingSessionId, - val error: com.tomasrepcik.sensorbox.core.error.AppError, - ) : RecordingEvent + data class RecordingStarted(override val sessionId: RecordingSessionId) : RecordingEvent data class RecordingStopped( override val sessionId: RecordingSessionId, val reason: RecordingStopReason, - val result: com.tomasrepcik.sensorbox.core.error.AppResult, + val result: AppResult, ) : RecordingEvent } diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt index be905f9..9a5ee5e 100644 --- a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/RecordingSource.kt @@ -1,19 +1,16 @@ package com.tomasrepcik.sensorbox.recording +import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow interface RecordingSource { val type: RecordingSourceType + val failures: Flow + get() = emptyFlow() suspend fun start(spec: RecordingSourceSpec): AppResult - suspend fun stop(): AppResult -} - -fun interface RecordingClock { - fun epochMillis(): Long -} - -fun interface RecordingDelay { - suspend fun pause(delayMillis: Long) + suspend fun stop(context: RecordingStopContext): AppResult } diff --git a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt index 7223d35..96c158e 100644 --- a/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt +++ b/recording-core/src/test/kotlin/com/tomasrepcik/sensorbox/recording/RecordingEngineTest.kt @@ -4,6 +4,8 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -13,6 +15,18 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class RecordingEngineTest { + @Test + fun `Given no active recording When stop is requested Then conflict is returned`() = runTest { + // Given + val engine = engine() + + // When + val result = engine.stop(RecordingStopReason.USER_REQUEST) + + // Then + assertEquals(AppErrorCode.CONFLICT, result.errorOrNull()?.code) + } + @Test fun `Given unordered sources When recording starts Then sources start in stable order`() = runTest { // Given @@ -52,7 +66,10 @@ class RecordingEngineTest { // Then assertTrue(result.isFailure) - assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) + assertEquals( + listOf("stop:GPS:SOURCE_FAILURE", "stop:SENSOR:SOURCE_FAILURE"), + calls.takeLast(2), + ) } @Test @@ -70,12 +87,36 @@ class RecordingEngineTest { engine.start(plan) // When - val result = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + val result = engine.stop(RecordingStopReason.USER_REQUEST) // Then assertTrue(result.isFailure) - assertTrue(calls.contains("stop:GPS")) - assertTrue(calls.contains("stop:SENSOR")) + assertTrue(calls.contains("stop:GPS:USER_REQUEST")) + assertTrue(calls.contains("stop:SENSOR:USER_REQUEST")) + } + + @Test + fun `Given runtime and stop failures When session stops Then session receives every failure`() = runTest { + // Given + val calls = mutableListOf() + val session = FakeSource(RecordingSourceType.SESSION, calls) + val sensor = FakeSource(RecordingSourceType.SENSOR, calls, stopFails = true) + val engine = RecordingEngine( + sources = listOf(session, sensor), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + ) + engine.start(plan(RecordingSourceSpec.Session, RecordingSourceSpec.Sensors(setOf(1), 0))) + + // When + sensor.reportFailure() + runCurrent() + + // Then + assertEquals( + listOf("Record SENSOR", "Stop SENSOR"), + session.stopContext?.failures?.map(AppError::operation), + ) } @Test @@ -87,12 +128,12 @@ class RecordingEngineTest { engine.start(plan) // When - val first = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) - val second = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + val first = engine.stop(RecordingStopReason.USER_REQUEST) + val second = engine.stop(RecordingStopReason.USER_REQUEST) // Then assertEquals(first, second) - assertEquals(1, calls.count { it == "stop:SENSOR" }) + assertEquals(1, calls.count { it == "stop:SENSOR:USER_REQUEST" }) } @Test @@ -102,8 +143,7 @@ class RecordingEngineTest { val engine = RecordingEngine( sources = listOf(FakeSource(RecordingSourceType.SENSOR, calls)), scope = backgroundScope, - clock = RecordingClock { testScheduler.currentTime }, - delay = RecordingDelay { kotlinx.coroutines.delay(it) }, + waitFor = { kotlinx.coroutines.delay(it) }, ) val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) @@ -113,18 +153,39 @@ class RecordingEngineTest { runCurrent() // Then - assertEquals(1, calls.count { it == "stop:SENSOR" }) + assertEquals(1, calls.count { it == "stop:SENSOR:DURATION_EXPIRED" }) + } + + @Test + fun `Given a running source When it reports failure Then the recording stops`() = runTest { + // Given + val calls = mutableListOf() + val source = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = RecordingEngine( + sources = listOf(source), + scope = backgroundScope, + waitFor = { kotlinx.coroutines.delay(it) }, + ) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) + engine.start(plan) + + // When + source.reportFailure() + runCurrent() + + // Then + assertEquals(1, calls.count { it == "stop:SENSOR:SOURCE_FAILURE" }) + assertTrue(engine.stop(RecordingStopReason.USER_REQUEST).isFailure) } private fun engine(vararg sources: RecordingSource) = RecordingEngine( sources = sources.toList(), scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), - clock = RecordingClock { 0L }, - delay = RecordingDelay { }, + waitFor = { }, ) private fun plan(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingPlan( - sessionId = RecordingSessionId.create(), + sessionId = RecordingSessionId("session"), sources = sources.toList(), durationMillis = durationMillis, ) @@ -136,16 +197,26 @@ private class FakeSource( private val startFails: Boolean = false, private val stopFails: Boolean = false, ) : RecordingSource { + private val mutableFailures = MutableSharedFlow(replay = 1) + override val failures: Flow = mutableFailures + var stopContext: RecordingStopContext? = null + private set + override suspend fun start(spec: RecordingSourceSpec): AppResult { calls += "start:$type" return if (startFails) failure("Start $type") else AppResult.success(Unit) } - override suspend fun stop(): AppResult { - calls += "stop:$type" + override suspend fun stop(context: RecordingStopContext): AppResult { + stopContext = context + calls += "stop:$type:${context.reason}" return if (stopFails) failure("Stop $type") else AppResult.success(Unit) } + fun reportFailure() { + mutableFailures.tryEmit(AppError(AppErrorCode.MEASUREMENT, "Record $type")) + } + private fun failure(operation: String): AppResult = AppResult.failure( AppError(AppErrorCode.MEASUREMENT, operation), ) diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index 36dbcf8..9b3c23f 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.play.services.location) implementation(libs.coroutines.core) + implementation(libs.coroutines.play.services) implementation(libs.hilt.android) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt index 088d7d4..ed056c0 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/GPSHandler.kt @@ -11,7 +11,6 @@ import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority -import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult @@ -21,117 +20,71 @@ import com.tomasrepcik.sensorbox.core.error.flatMap class GPSHandler(private val locationClient: FusedLocationProviderClient? = null) : LocationCallback() { private var callback: OnLocationChangedCallback? = null - private lateinit var request: LocationRequest - - private var locationAvailability: LocationAvailability? = null private var lastLocation: Location? = null - private var registered: Boolean = false - private var intervalSeconds: Int = DEFAULT_INTERVAL_SECONDS - private var minDistanceMeters: Int = DEFAULT_DISTANCE_METERS - private val tag = "GPS_location" - - /** - * calls for last known location and registers location callback - * - * @param context - */ - private fun initialize(client: FusedLocationProviderClient) { - request = createRequest() - + private fun initialize(client: FusedLocationProviderClient, intervalSeconds: Int, minDistanceMeters: Int) { client.lastLocation.addOnSuccessListener { location: Location? -> - if (location == null) { - callback?.onLastLocationSuccess(null) - } else { - lastLocation = location - callback?.onLastLocationSuccess(location) - } + lastLocation = location + callback?.onLastLocationSuccess(location) }.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Read last GPS location", error) + Log.e(TAG, "Cannot read last GPS location", error) callback?.onLastLocationSuccess(null) } + val request = createRequest(intervalSeconds, minDistanceMeters) client.requestLocationUpdates(request, this, Looper.getMainLooper()).addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Request GPS updates", error) + Log.e(TAG, "Cannot request GPS updates", error) } - registered = true } - /** - * saves last location and is passed if the new callback registers - * - * @param locationResult - */ override fun onLocationResult(locationResult: LocationResult) { - super.onLocationResult(locationResult) - if (locationResult.locations.isNotEmpty()) { - lastLocation = locationResult.lastLocation - if (lastLocation != null) { - callback?.onLocationChanged(lastLocation) - } - } + val location = locationResult.lastLocation ?: return + lastLocation = location + callback?.onLocationChanged(location) } - /** Reports provider availability changes to the active measurement. */ override fun onLocationAvailability(locationAvailability: LocationAvailability) { - super.onLocationAvailability(locationAvailability) - this.locationAvailability = locationAvailability callback?.onAvailabilityChanged(locationAvailability) } - /** Stops location updates for the active measurement. */ fun gpsOff(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Stop GPS updates") { - if (registered) { - Log.i(tag, "Logging off location") + if (callback != null) { locationClient?.flushLocations()?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Flush GPS updates", error) + Log.e(TAG, "Cannot flush GPS updates", error) } locationClient?.removeLocationUpdates(this)?.addOnFailureListener { error -> - AppError.from(AppErrorCode.MEASUREMENT, "Remove GPS updates", error) + Log.e(TAG, "Cannot remove GPS updates", error) } } - registered = false + callback = null } - /** Creates a request from the immutable measurement configuration. */ - private fun createRequest(): LocationRequest { - val builder = LocationRequest.Builder( - Priority.PRIORITY_HIGH_ACCURACY, - intervalSeconds * 1000L, - ) - builder.setMinUpdateDistanceMeters(minDistanceMeters.toFloat()) - builder.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) - builder.setWaitForAccurateLocation(true) - Log.i("GPS", "location request created") - return builder.build() - } - - fun configure(intervalSeconds: Int, minDistanceMeters: Int) { - this.intervalSeconds = intervalSeconds.coerceIn(1, MAX_INTERVAL_SECONDS) - this.minDistanceMeters = minDistanceMeters.coerceIn(0, MAX_DISTANCE_METERS) - } - - /** - * adding callback to pass location - * - * @param context - * @param gpsCallback - this object will get access to location and updates, previous is forgotten - * - */ - fun addCallback(gpsCallback: OnLocationChangedCallback): AppResult = - (if (registered) gpsOff() else AppResult.success(Unit)).flatMap { - appResult(AppErrorCode.MEASUREMENT, "Register GPS callback") { - val client = checkNotNull(locationClient) { "GPS client is unavailable" } - callback = gpsCallback - initialize(client) - gpsCallback.onLocationChanged(lastLocation) - } + private fun createRequest(intervalSeconds: Int, minDistanceMeters: Int): LocationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + intervalSeconds.coerceIn(1, MAX_INTERVAL_SECONDS) * 1_000L, + ) + .setMinUpdateDistanceMeters(minDistanceMeters.coerceIn(0, MAX_DISTANCE_METERS).toFloat()) + .setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) + .setWaitForAccurateLocation(true) + .build() + + fun addCallback( + gpsCallback: OnLocationChangedCallback, + intervalSeconds: Int = DEFAULT_INTERVAL_SECONDS, + minDistanceMeters: Int = DEFAULT_DISTANCE_METERS, + ): AppResult = (if (callback != null) gpsOff() else AppResult.success(Unit)).flatMap { + appResult(AppErrorCode.MEASUREMENT, "Register GPS callback") { + val client = checkNotNull(locationClient) { "GPS client is unavailable" } + callback = gpsCallback + initialize(client, intervalSeconds, minDistanceMeters) + gpsCallback.onLocationChanged(lastLocation) } + } interface OnLocationChangedCallback { fun onLocationChanged(location: Location?) - fun onLastLocationSuccess(location: Location?) - fun onAvailabilityChanged(locationAvailability: LocationAvailability?) + fun onLastLocationSuccess(location: Location?) = Unit + fun onAvailabilityChanged(locationAvailability: LocationAvailability?) = Unit } private companion object { @@ -139,5 +92,6 @@ class GPSHandler(private val locationClient: FusedLocationProviderClient? = null const val DEFAULT_DISTANCE_METERS = 20 const val MAX_INTERVAL_SECONDS = 3_600 const val MAX_DISTANCE_METERS = 10_000 + const val TAG = "SensorBox GPS" } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt index 43ce3ee..667f08f 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurement.kt @@ -5,22 +5,33 @@ import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.error.withAppError import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.io.OutputStream /** 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 writeFailure: AppError? = null + private val mutableFailures = MutableSharedFlow(replay = 1) - fun prepare(folderName: String, useInternalStorage: Boolean): AppResult { + val failures: Flow = mutableFailures.asSharedFlow() + + suspend fun start(folderName: String, useInternalStorage: Boolean, periodSeconds: Int): AppResult { + val opened = openOutputs(folderName, useInternalStorage) + if (opened.isFailure) return opened + return platform.start(periodSeconds, ::writeActivityUpdate, ::writeActivityTransitions) + .withAppError(AppErrorCode.MEASUREMENT, "Start activity recognition") + } + + private fun openOutputs(folderName: String, useInternalStorage: Boolean): AppResult { val updatesResult = storage.openMeasurementFile( folderName = folderName, mimeType = "text/csv", @@ -51,15 +62,9 @@ internal class ActivityRecognitionMeasurement( updates.write(UPDATES_HEADER.toByteArray()) transitions.write(TRANSITIONS_HEADER.toByteArray()) } - return headers.flatMap { - platform.prepare(::writeActivityUpdate, ::writeActivityTransitions) - } - .withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") + return headers.withAppError(AppErrorCode.MEASUREMENT, "Initialize activity recognition") } - 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") @@ -96,7 +101,10 @@ internal class ActivityRecognitionMeasurement( private inline fun recordWriteFailure(operation: String, block: () -> Unit) { if (writeFailure != null) return - appResult(AppErrorCode.STORAGE, operation, block).onFailure { writeFailure = it } + appResult(AppErrorCode.STORAGE, operation, block).onFailure { error -> + writeFailure = error + mutableFailures.tryEmit(error) + } } private companion object { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt index 8254c96..a569e50 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionPlatform.kt @@ -21,6 +21,9 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.error.suspendAppResult +import kotlinx.coroutines.tasks.await internal data class ActivityUpdate(val elapsedRealtimeMillis: Long, val confidences: List) @@ -31,14 +34,13 @@ internal data class ActivityTransitionSample( ) internal interface ActivityRecognitionPlatform { - fun prepare( + suspend fun start( + periodSeconds: Int, onUpdate: (ActivityUpdate) -> Unit, onTransitions: (List) -> Unit, ): AppResult - fun start(periodSeconds: Int): AppResult - - fun stop(): AppResult + suspend fun stop(): AppResult } internal class AndroidActivityRecognitionPlatform(private val context: Context) : ActivityRecognitionPlatform { @@ -76,7 +78,30 @@ internal class AndroidActivityRecognitionPlatform(private val context: Context) } } - override fun prepare( + @SuppressLint("MissingPermission") + override suspend fun start( + periodSeconds: Int, + onUpdate: (ActivityUpdate) -> Unit, + onTransitions: (List) -> Unit, + ): AppResult { + if (!hasPermission()) { + return AppResult.failure(AppError(AppErrorCode.PERMISSION, "Start activity recognition")) + } + val prepared = prepareResources(onUpdate, onTransitions) + if (prepared.isFailure) return prepared + return suspendAppResult(AppErrorCode.MEASUREMENT, "Start activity recognition") { + val activeClient = checkNotNull(client) { "Activity recognition client is missing" } + val updates = checkNotNull(updatesPendingIntent) { "Activity update request is missing" } + val transitions = checkNotNull(transitionsPendingIntent) { "Activity transition request is missing" } + activeClient.requestActivityUpdates(periodSeconds.coerceAtLeast(1) * 1_000L, updates).await() + activeClient.requestActivityTransitionUpdates( + ActivityTransitionRequest(ACTIVITY_TRANSITIONS), + transitions, + ).await() + } + } + + private fun prepareResources( onUpdate: (ActivityUpdate) -> Unit, onTransitions: (List) -> Unit, ): AppResult = appResult(AppErrorCode.MEASUREMENT, "Initialize activity recognition resources") { @@ -101,33 +126,28 @@ internal class AndroidActivityRecognitionPlatform(private val context: Context) } @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) + override suspend fun stop(): AppResult { + val results = mutableListOf>() + updatesPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.MEASUREMENT, "Remove activity updates") { + client?.removeActivityUpdates(pendingIntent)?.await() } - transitionsPendingIntent?.let { pendingIntent -> - client?.requestActivityTransitionUpdates(ActivityTransitionRequest(ACTIVITY_TRANSITIONS), pendingIntent) + } + transitionsPendingIntent?.let { pendingIntent -> + results += suspendAppResult(AppErrorCode.MEASUREMENT, "Remove activity transitions") { + client?.removeActivityTransitionUpdates(pendingIntent)?.await() } } - } - - @SuppressLint("MissingPermission") - override fun stop(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Pause activity recognition") { - if (hasPermission()) { - updatesPendingIntent?.let { client?.removeActivityUpdates(it) } - transitionsPendingIntent?.let { client?.removeActivityTransitionUpdates(it) } + results += appResult(AppErrorCode.MEASUREMENT, "Release activity recognition resources") { + if (receiverRegistered) context.unregisterReceiver(receiver) + receiverRegistered = false + client = null + updatesPendingIntent = null + transitionsPendingIntent = null + updateCallback = null + transitionCallback = null } - if (receiverRegistered) context.unregisterReceiver(receiver) - receiverRegistered = false - client = null - updatesPendingIntent = null - transitionsPendingIntent = null - updateCallback = null - transitionCallback = null + return results.combineAppResults(AppErrorCode.MEASUREMENT, "Pause activity recognition") } private fun hasPermission(): Boolean = Build.VERSION.SDK_INT < 29 || diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt index 574de38..6225cfd 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ExtraInfoHandler.kt @@ -3,6 +3,7 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements import android.hardware.Sensor import android.hardware.SensorManager import android.os.SystemClock +import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult @@ -10,8 +11,10 @@ import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.error.withAppError import com.tomasrepcik.sensorbox.core.time.ClockFormats import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.recording.RecordingStopContext import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.serviceController.MeasurementConfig +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest +import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -22,20 +25,21 @@ internal class ExtraInfoHandler( private val clock: EpochClock, private val sensorManager: SensorManager, ) { - private var config: MeasurementConfig? = null + private var request: MeasurementLaunchRequest? = null private var startedAtMillis: Long = 0L private var startedAtNanos: Long = 0L + private var sensorStats: List = emptyList() private val annotations = mutableListOf() private val triggeredAlarms = mutableListOf() - private var written = false - fun start(config: MeasurementConfig) { - this.config = config - startedAtMillis = clock.nowMillis() - startedAtNanos = SystemClock.elapsedRealtimeNanos() + fun start(request: MeasurementLaunchRequest) { + this.request = request + val startedAt = capturePhoneTime() + startedAtMillis = startedAt.unixMillis + startedAtNanos = startedAt.elapsedRealtimeNanos annotations.clear() triggeredAlarms.clear() - written = false + sensorStats = emptyList() } fun annotate(timestampMillis: Long, text: String) { @@ -46,14 +50,20 @@ internal class ExtraInfoHandler( triggeredAlarms += timestampMillis } - fun write(): AppResult { - val active = config ?: return AppResult.success(Unit) - if (written) return AppResult.success(Unit) - written = true + fun recordSensorStats(stats: List) { + sensorStats = stats + } + + fun write(context: RecordingStopContext): AppResult { + val active = request ?: return AppResult.success(Unit) + request = null + val endedAt = capturePhoneTime() val metadataResult = appResult(AppErrorCode.MEASUREMENT, "Build measurement metadata") { MeasurementMetadata( millis = startedAtMillis, nanos = startedAtNanos, + endedAtMillis = endedAt.unixMillis, + endedAtNanos = endedAt.elapsedRealtimeNanos, type = RECORDING_TYPE, date = ClockFormats.metadataTimestamp(startedAtMillis), folder = active.folderName, @@ -63,6 +73,14 @@ internal class ExtraInfoHandler( alarms = triggeredAlarms.toList(), configuredAlarmOffsetsSeconds = active.alarmOffsetsSeconds.toList(), durationMillis = active.durationMillis, + actualDurationMillis = ((endedAt.elapsedRealtimeNanos - startedAtNanos) / NANOS_PER_MILLISECOND) + .coerceAtLeast(0L), + stopReason = context.reason.name, + failureOperation = context.failure?.operation, + failureMessage = context.failure?.diagnosticMessage, + failures = context.failures.map(AppError::toMeasurementFailure), + wakeLockEnabled = active.requiresWakeLock, + sensorFiles = sensorStats, activityRecognition = active.activityRecognition, significantMotion = active.significantMotion, ) @@ -81,7 +99,7 @@ internal class ExtraInfoHandler( }.withAppError(AppErrorCode.MEASUREMENT, "Write measurement metadata") } - private fun sensorRanges(active: MeasurementConfig): List { + private fun sensorRanges(active: MeasurementLaunchRequest): List { val rangeIds = if (active.significantMotion) { active.sensorIds + Sensor.TYPE_SIGNIFICANT_MOTION } else { @@ -94,21 +112,36 @@ internal class ExtraInfoHandler( } } + private fun capturePhoneTime(): PhoneTime { + val before = SystemClock.elapsedRealtimeNanos() + val unixMillis = clock.nowMillis() + val after = SystemClock.elapsedRealtimeNanos() + return PhoneTime( + unixMillis = unixMillis, + elapsedRealtimeNanos = before + (after - before) / 2, + ) + } + private companion object { const val EXTRA_FILE = "extra.json" const val RECORDING_TYPE = "RECORDING" + const val NANOS_PER_MILLISECOND = 1_000_000L val JSON = Json { prettyPrint = true prettyPrintIndent = " " encodeDefaults = true } } + + private data class PhoneTime(val unixMillis: Long, val elapsedRealtimeNanos: Long) } @Serializable internal data class MeasurementMetadata( val millis: Long, val nanos: Long, + val endedAtMillis: Long, + val endedAtNanos: Long, val type: String, val date: String, val folder: String, @@ -118,6 +151,13 @@ internal data class MeasurementMetadata( val alarms: List, val configuredAlarmOffsetsSeconds: List, val durationMillis: Long, + val actualDurationMillis: Long, + val stopReason: String, + val failureOperation: String?, + val failureMessage: String?, + val failures: List, + val wakeLockEnabled: Boolean, + val sensorFiles: List, val activityRecognition: Boolean, val significantMotion: Boolean, ) @@ -127,3 +167,20 @@ internal data class MeasurementAnnotation(val timestamp: Long, val annotation: S @Serializable internal data class SensorRange(val sensor: String, val type: Int, val range: Float) + +@Serializable +internal data class MeasurementFailure( + val code: String, + val operation: String, + val message: String, + val cause: String?, + val context: Map, +) + +private fun AppError.toMeasurementFailure() = MeasurementFailure( + code = code.name, + operation = operation, + message = diagnosticMessage, + cause = cause?.let { error -> "${error::class.java.simpleName}: ${error.message.orEmpty()}" }, + context = context, +) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt index 163aa9a..dcb36f6 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/GPSMeasurement.kt @@ -1,25 +1,23 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements import android.location.Location -import com.google.android.gms.location.LocationAvailability import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.error.withAppError import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext import java.io.OutputStream -/** - * implements GPS handler to gather GPS data and write them into CSV - * - * @property gpsHandler - manages access to GPS - */ internal class GPSMeasurement( private val gpsHandler: GPSHandler, private val storage: MeasurementStorage, @@ -28,24 +26,21 @@ internal class GPSMeasurement( private var outputStream: OutputStream? = null private var writeFailure: AppError? = null - private val header: String = "time_millis;latitude;longitude;altitude;accuracy;speed;bearing;provider\n" + private val mutableFailures = MutableSharedFlow(replay = 1) - /** - * creates outputStream based on the internal storage requirement - * - * @param context - * @param params - from the service - */ - fun prepare( + val failures: Flow = mutableFailures.asSharedFlow() + + fun start( folderName: String, useInternalStorage: Boolean, intervalSeconds: Int, minimumDistanceMeters: Int, - ): AppResult { - gpsHandler.configure( - intervalSeconds = intervalSeconds, - minDistanceMeters = minimumDistanceMeters, - ) + ): AppResult = openOutput(folderName, useInternalStorage).flatMap { + gpsHandler.addCallback(this, intervalSeconds, minimumDistanceMeters) + .withAppError(AppErrorCode.MEASUREMENT, "Start GPS") + } + + private fun openOutput(folderName: String, useInternalStorage: Boolean): AppResult { val streamResult = storage.openMeasurementFile( folderName = folderName, mimeType = "text/csv", @@ -57,18 +52,12 @@ internal class GPSMeasurement( .withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") outputStream = output val headerResult = appResult(AppErrorCode.STORAGE, "Write GPS header") { - output.write(header.toByteArray()) + output.write(HEADER.toByteArray()) } return headerResult.withAppError(AppErrorCode.MEASUREMENT, "Initialize GPS measurement") } - /** - * parses attributes of the Location property - * - * @param location - location from the GPS - * @return - formatted line of the csv - */ - private fun createLocationStamp(location: Location): String = "${clock.nowMillis()};" + + private fun locationRow(location: Location): String = "${clock.nowMillis()};" + "${location.latitude};" + "${location.longitude};" + "${location.altitude};" + @@ -77,29 +66,10 @@ internal class GPSMeasurement( "${location.bearing};" + location.provider + "\n" - /** - * adds callback for the GPS - * - * @param context - */ - - fun start(): AppResult = gpsHandler.addCallback(this) - .withAppError(AppErrorCode.MEASUREMENT, "Start GPS") - - /** - * turns off the GPS - * - * @param context - */ private fun pause(): AppResult = gpsHandler.gpsOff() .withAppError(AppErrorCode.MEASUREMENT, "Pause GPS") - /** - * outputStream is saved and closed - * - * @param context - */ - private suspend fun save(): AppResult { + private fun save(): AppResult { val stream = outputStream val results = mutableListOf>() results += appResult(AppErrorCode.STORAGE, "Flush GPS measurement") { stream?.flush() } @@ -110,30 +80,24 @@ internal class GPSMeasurement( return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save GPS measurement") } - /** - * save of the csv file and GPS is turned off - * - * @param context - */ suspend fun stop(): AppResult = listOf( withContext(Dispatchers.Main) { pause() }, withContext(Dispatchers.IO) { save() }, ).combineAppResults(AppErrorCode.MEASUREMENT, "Stop GPS measurement") - /** - * called on GPS change - * - * @param location - latest location - */ override fun onLocationChanged(location: Location?) { - location?.let { loc: Location -> - appResult(AppErrorCode.STORAGE, "Write GPS sample") { - outputStream?.write(createLocationStamp(loc).toByteArray()) - }.onFailure { writeFailure = it } + val sample = location ?: return + appResult(AppErrorCode.STORAGE, "Write GPS sample") { + outputStream?.write(locationRow(sample).toByteArray()) + }.onFailure { error -> + if (writeFailure == null) { + writeFailure = error + mutableFailures.tryEmit(error) + } } } - override fun onLastLocationSuccess(location: Location?) = Unit - - override fun onAvailabilityChanged(locationAvailability: LocationAvailability?) = Unit + private companion object { + const val HEADER = "time_millis;latitude;longitude;altitude;accuracy;speed;bearing;provider\n" + } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt index 2f1d143..e75015f 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SensorMeasurement.kt @@ -7,28 +7,37 @@ import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats import com.tomasrepcik.sensorbox.sensorservices.types.SensorHolder import com.tomasrepcik.sensorbox.sensorservices.types.SensorSpec +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow internal class SensorMeasurement( private val storage: MeasurementStorage, private val diagnosticLogger: DiagnosticLogger, - private val clock: EpochClock, private val sensorManager: SensorManager? = null, + private val onStopped: (List) -> Unit = {}, ) { private val holders = mutableListOf() - private var samplingPeriod = SensorManager.SENSOR_DELAY_FASTEST + private val mutableFailures = MutableSharedFlow(replay = 1) - fun prepare( + val failures: Flow = mutableFailures.asSharedFlow() + + fun start( folderName: String, useInternalStorage: Boolean, sensorTypes: Set, samplingPeriod: Int, - ): AppResult { - this.samplingPeriod = samplingPeriod + ): AppResult = openFiles(folderName, useInternalStorage, sensorTypes).flatMap { + registerSensors(samplingPeriod) + } + + private fun openFiles(folderName: String, useInternalStorage: Boolean, sensorTypes: Set): AppResult { for (sensorType in sensorTypes.sorted()) { val holderResult = createHolder(folderName, useInternalStorage, sensorType) if (holderResult.isFailure) { @@ -51,14 +60,16 @@ internal class SensorMeasurement( mimeType = "text/csv", fileName = spec.fileName, useInternalStorage = useInternalStorage, - ).map { SensorHolder(spec, it, diagnosticLogger, clock) } + ).map { output -> + SensorHolder(spec, output, diagnosticLogger) { error -> mutableFailures.tryEmit(error) } + } } - fun start(): AppResult { + private fun registerSensors(samplingPeriod: Int): AppResult { val sensorManager = sensorManager ?: return AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Access sensor manager")) for (holder in holders) { - val registration = registerHolder(sensorManager, holder) + val registration = registerHolder(sensorManager, holder, samplingPeriod) if (registration.isFailure) { return registration.withAppError(AppErrorCode.MEASUREMENT, "Start sensors") } @@ -66,7 +77,11 @@ internal class SensorMeasurement( return AppResult.success(Unit) } - private fun registerHolder(sensorManager: SensorManager, holder: SensorHolder): AppResult { + private fun registerHolder( + sensorManager: SensorManager, + holder: SensorHolder, + samplingPeriod: Int, + ): 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}") { @@ -82,15 +97,13 @@ internal class SensorMeasurement( } } - private fun pause(): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Pause sensors", - ) { + private fun pause(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Pause sensors") { sensorManager?.let { manager -> holders.forEach(manager::unregisterListener) } } private suspend fun save(): AppResult { val results = holders.map { it.close() } + onStopped(holders.map(SensorHolder::stats)) holders.clear() return results.combineAppResults(AppErrorCode.MEASUREMENT, "Save sensors") } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt index 94beef4..320e3b5 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/SignificantMotion.kt @@ -11,21 +11,32 @@ import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.core.error.withAppError -import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.io.OutputStream /** Handles Android's one-shot significant-motion trigger and re-arms it after every event. */ -internal class SignificantMotion( - private val storage: MeasurementStorage, - private val clock: EpochClock, - private val sensorManager: SensorManager, -) : TriggerEventListener() { +internal class SignificantMotion(private val storage: MeasurementStorage, private val sensorManager: SensorManager) : + TriggerEventListener() { private var sensor: Sensor? = null private var output: OutputStream? = null private var writeFailure: AppError? = null + private val mutableFailures = MutableSharedFlow(replay = 1) - fun prepare(folderName: String, useInternalStorage: Boolean): AppResult { + val failures: Flow = mutableFailures.asSharedFlow() + + fun start(folderName: String, useInternalStorage: Boolean): AppResult = + openOutput(folderName, useInternalStorage).flatMap { + if (arm()) { + AppResult.success(Unit) + } else { + AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Start significant motion")) + } + } + + private fun openOutput(folderName: String, useInternalStorage: Boolean): AppResult { sensor = sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) val stream = storage.openMeasurementFile( folderName = folderName, @@ -36,17 +47,11 @@ internal class SignificantMotion( return stream.flatMap { opened -> output = opened appResult(AppErrorCode.STORAGE, "Write significant motion header") { - opened.write("t_unix;event\n".toByteArray()) + opened.write("t_sensor;event\n".toByteArray()) } }.withAppError(AppErrorCode.MEASUREMENT, "Initialize significant motion") } - fun start(): AppResult = if (arm()) { - AppResult.success(Unit) - } else { - AppResult.failure(AppError(AppErrorCode.MEASUREMENT, "Start significant motion")) - } - private fun pause(): AppResult = appResult( AppErrorCode.MEASUREMENT, "Pause significant motion", @@ -54,7 +59,7 @@ internal class SignificantMotion( sensor?.let { sensorManager.cancelTriggerSensor(this, it) } } - private suspend fun save(): AppResult { + private fun save(): AppResult { val results = mutableListOf>() results += appResult(AppErrorCode.STORAGE, "Close significant motion") { output?.close() } writeFailure?.let { results += AppResult.failure(it) } @@ -72,10 +77,16 @@ internal class SignificantMotion( override fun onTrigger(event: TriggerEvent?) { event?.values?.firstOrNull()?.let { value -> appResult(AppErrorCode.STORAGE, "Write significant motion") { - output?.write("${clock.nowMillis()};$value\n".toByteArray()) - }.onFailure { writeFailure = it } + output?.write("${event.timestamp};$value\n".toByteArray()) + }.onFailure(::reportFailure) } - if (!arm()) AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion") + if (!arm()) reportFailure(AppError(AppErrorCode.MEASUREMENT, "Re-arm significant motion")) + } + + private fun reportFailure(error: AppError) { + if (writeFailure != null) return + writeFailure = error + mutableFailures.tryEmit(error) } private fun arm(): Boolean = sensor?.let { sensorManager.requestTriggerSensor(this, it) } == true diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt index 655eca1..6089514 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/intent/MeasurementLaunchRequest.kt @@ -1,7 +1,12 @@ package com.tomasrepcik.sensorbox.sensorservices.intent +import android.content.Intent +import android.hardware.SensorManager +import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService +import java.util.UUID + data class MeasurementLaunchRequest( - val sessionId: String = java.util.UUID.randomUUID().toString(), + val sessionId: String = UUID.randomUUID().toString(), val folderName: String, val useInternalStorage: Boolean, val sensorIds: Set, @@ -17,4 +22,35 @@ data class MeasurementLaunchRequest( val activityRecognition: Boolean = false, val activityRecognitionPeriodSeconds: Int = 30, val significantMotion: Boolean = false, -) +) { + val requiresWakeLock: Boolean + get() = useWakeLock || sensorIds.isNotEmpty() + + companion object { + fun from(intent: Intent): MeasurementLaunchRequest = MeasurementLaunchRequest( + sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), + folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), + useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), + sensorIds = intent.getIntArrayExtra(MeasurementService.ANDROID_SENSORS)?.toSet().orEmpty(), + sensorSamplingPeriod = intent.getIntExtra( + MeasurementService.ANDROID_SENSORS_SPEED, + SensorManager.SENSOR_DELAY_FASTEST, + ), + includesGps = intent.getBooleanExtra(MeasurementService.GPS, false), + stopOnLowBattery = intent.getBooleanExtra(MeasurementService.STOP_ON_LOW_BATTERY, false), + useWakeLock = intent.getBooleanExtra(MeasurementService.USE_WAKE_LOCK, false), + gpsIntervalSeconds = intent.getIntExtra(MeasurementService.GPS_INTERVAL_SECONDS, 10), + gpsMinDistanceMeters = intent.getIntExtra(MeasurementService.GPS_DISTANCE_METERS, 20), + durationMillis = intent.getLongExtra(MeasurementService.DURATION_MILLIS, 0L).coerceAtLeast(0L), + notes = intent.getStringArrayListExtra(MeasurementService.NOTES).orEmpty(), + alarmOffsetsSeconds = intent.getIntArrayExtra(MeasurementService.ALARM_OFFSETS_SECONDS) + ?.filter { it >= 0 }.orEmpty(), + activityRecognition = intent.getBooleanExtra(MeasurementService.ACTIVITY_RECOGNITION, false), + activityRecognitionPeriodSeconds = intent.getIntExtra( + MeasurementService.ACTIVITY_RECOGNITION_PERIOD_SECONDS, + 30, + ).coerceAtLeast(1), + significantMotion = intent.getBooleanExtra(MeasurementService.SIGNIFICANT_MOTION, false), + ) + } +} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt index e9abc99..0c57781 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ActivityRecordingSource.kt @@ -1,34 +1,34 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.recording.RecordingStopContext import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionMeasurement import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ActivityRecognitionPlatform +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest internal class ActivityRecordingSource( - private val config: MeasurementConfig, + private val request: MeasurementLaunchRequest, private val storage: MeasurementStorage, private val platform: ActivityRecognitionPlatform, ) : RecordingSource { - private var measurement: ActivityRecognitionMeasurement? = null + private val measurement = ActivityRecognitionMeasurement(storage, platform) override val type = RecordingSourceType.ACTIVITY_RECOGNITION + override val failures = measurement.failures override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.ActivityRecognition) { - val recording = ActivityRecognitionMeasurement(spec.periodSeconds, storage, platform) - measurement = recording - recording.prepare(config.folderName, config.useInternalStorage).flatMap { recording.start() } + measurement.start( + folderName = request.folderName, + useInternalStorage = request.useInternalStorage, + periodSeconds = spec.periodSeconds, + ) } else { invalidSpec(type) } - override suspend fun stop(): AppResult { - val result = measurement?.stop() ?: AppResult.success(Unit) - measurement = null - return result - } + override suspend fun stop(context: RecordingStopContext): AppResult = measurement.stop() } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt index 0fa02c4..1e3e3ed 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/AndroidRecordingSources.kt @@ -13,39 +13,45 @@ import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.AndroidAct import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GPSMeasurement import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorMeasurement import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotion +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest internal class AndroidRecordingSources( context: Context, - config: MeasurementConfig, + request: MeasurementLaunchRequest, storage: MeasurementStorage, diagnosticLogger: DiagnosticLogger, clock: EpochClock, ) { private val sensorManager = context.getSystemService(SensorManager::class.java) - private val artifacts = SessionArtifacts(config, storage, clock, sensorManager) + private val session = SessionRecordingSource(request, storage, clock, sensorManager) val sources: List = listOf( - SessionRecordingSource(artifacts), + session, SensorRecordingSource( - config, - SensorMeasurement(storage, diagnosticLogger, clock, sensorManager), + request, + SensorMeasurement( + storage = storage, + diagnosticLogger = diagnosticLogger, + sensorManager = sensorManager, + onStopped = session::recordSensorStats, + ), ), GpsRecordingSource( - config, + request, GPSMeasurement(GPSHandler(LocationServices.getFusedLocationProviderClient(context)), storage, clock), ), ActivityRecordingSource( - config, + request, storage, AndroidActivityRecognitionPlatform(context.applicationContext), ), SignificantMotionRecordingSource( - config, - SignificantMotion(storage, clock, sensorManager), + request, + SignificantMotion(storage, sensorManager), ), ) - fun annotate(timestampMillis: Long, text: String): AppResult = artifacts.annotate(timestampMillis, text) + fun annotate(timestampMillis: Long, text: String): AppResult = session.annotate(timestampMillis, text) - fun playAlarm(): AppResult = artifacts.playAlarm() + fun playAlarm(): AppResult = session.playAlarm() } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt index a9a3a1e..e031e90 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/GpsRecordingSource.kt @@ -1,26 +1,30 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.recording.RecordingStopContext import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.GPSMeasurement +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest -internal class GpsRecordingSource(private val config: MeasurementConfig, private val measurement: GPSMeasurement) : - RecordingSource { +internal class GpsRecordingSource( + private val request: MeasurementLaunchRequest, + private val measurement: GPSMeasurement, +) : RecordingSource { override val type = RecordingSourceType.GPS + override val failures = measurement.failures override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Gps) { - measurement.prepare( - folderName = config.folderName, - useInternalStorage = config.useInternalStorage, + measurement.start( + folderName = request.folderName, + useInternalStorage = request.useInternalStorage, intervalSeconds = spec.intervalSeconds, minimumDistanceMeters = spec.minimumDistanceMeters, - ).flatMap { measurement.start() } + ) } else { invalidSpec(type) } - override suspend fun stop(): AppResult = measurement.stop() + override suspend fun stop(context: RecordingStopContext): AppResult = measurement.stop() } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt deleted file mode 100644 index cf5b8e2..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/MeasurementConfig.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController - -import android.content.Intent -import android.hardware.SensorManager -import com.tomasrepcik.sensorbox.sensorservices.services.MeasurementService - -data class MeasurementConfig( - val sessionId: String, - val folderName: String, - val useInternalStorage: Boolean, - val sensorIds: IntArray, - val sensorSamplingPeriod: Int, - val includesGps: Boolean, - val stopOnLowBattery: Boolean, - val useWakeLock: Boolean, - val gpsIntervalSeconds: Int, - val gpsMinDistanceMeters: Int, - val durationMillis: Long, - val notes: List, - val alarmOffsetsSeconds: List, - val activityRecognition: Boolean, - val activityRecognitionPeriodSeconds: Int, - val significantMotion: Boolean, -) { - companion object { - fun from(intent: Intent): MeasurementConfig = MeasurementConfig( - sessionId = intent.getStringExtra(MeasurementService.SESSION_ID).orEmpty(), - folderName = intent.getStringExtra(MeasurementService.FOLDER_NAME).orEmpty(), - useInternalStorage = intent.getBooleanExtra(MeasurementService.INTERNAL_STORAGE, false), - sensorIds = intent.getIntArrayExtra(MeasurementService.ANDROID_SENSORS) ?: intArrayOf(), - sensorSamplingPeriod = intent.getIntExtra( - MeasurementService.ANDROID_SENSORS_SPEED, - SensorManager.SENSOR_DELAY_FASTEST, - ), - includesGps = intent.getBooleanExtra(MeasurementService.GPS, false), - stopOnLowBattery = intent.getBooleanExtra(MeasurementService.STOP_ON_LOW_BATTERY, false), - useWakeLock = intent.getBooleanExtra(MeasurementService.USE_WAKE_LOCK, false), - gpsIntervalSeconds = intent.getIntExtra(MeasurementService.GPS_INTERVAL_SECONDS, 10), - gpsMinDistanceMeters = intent.getIntExtra(MeasurementService.GPS_DISTANCE_METERS, 20), - durationMillis = intent.getLongExtra(MeasurementService.DURATION_MILLIS, 0L).coerceAtLeast(0L), - notes = intent.getStringArrayListExtra(MeasurementService.NOTES).orEmpty(), - alarmOffsetsSeconds = intent.getIntArrayExtra(MeasurementService.ALARM_OFFSETS_SECONDS) - ?.filter { it >= 0 }.orEmpty(), - activityRecognition = intent.getBooleanExtra(MeasurementService.ACTIVITY_RECOGNITION, false), - activityRecognitionPeriodSeconds = intent.getIntExtra( - MeasurementService.ACTIVITY_RECOGNITION_PERIOD_SECONDS, - 30, - ).coerceAtLeast(1), - significantMotion = intent.getBooleanExtra(MeasurementService.SIGNIFICANT_MOTION, false), - ) - } -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt index fa5a6b5..3cadc8c 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SensorRecordingSource.kt @@ -1,28 +1,30 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.recording.RecordingStopContext import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SensorMeasurement +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest internal class SensorRecordingSource( - private val config: MeasurementConfig, + private val request: MeasurementLaunchRequest, private val measurement: SensorMeasurement, ) : RecordingSource { override val type = RecordingSourceType.SENSOR + override val failures = measurement.failures override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Sensors) { - measurement.prepare( - folderName = config.folderName, - useInternalStorage = config.useInternalStorage, + measurement.start( + folderName = request.folderName, + useInternalStorage = request.useInternalStorage, sensorTypes = spec.sensorTypes, samplingPeriod = spec.samplingPeriod, - ).flatMap { measurement.start() } + ) } else { invalidSpec(type) } - override suspend fun stop(): AppResult = measurement.stop() + override suspend fun stop(context: RecordingStopContext): AppResult = measurement.stop() } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt index 625f0c6..15d4dcd 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceController.kt @@ -4,8 +4,6 @@ import android.content.Context import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.recording.RecordingClock -import com.tomasrepcik.sensorbox.recording.RecordingDelay import com.tomasrepcik.sensorbox.recording.RecordingEngine import com.tomasrepcik.sensorbox.recording.RecordingEvent import com.tomasrepcik.sensorbox.recording.RecordingPlan @@ -13,41 +11,39 @@ import com.tomasrepcik.sensorbox.recording.RecordingSessionId import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingStopReason import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharedFlow internal class ServiceController( context: Context, - private val config: MeasurementConfig, + private val request: MeasurementLaunchRequest, 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 androidSources = AndroidRecordingSources(context, request, storage, diagnosticLogger, clock) + private val sessionId = RecordingSessionId(request.sessionId) private val engine = RecordingEngine( sources = androidSources.sources, scope = scope, - clock = RecordingClock(clock::nowMillis), - delay = RecordingDelay { delayMillis -> delay(delayMillis) }, ) val events: SharedFlow = engine.events - suspend fun start(): AppResult = engine.start(config.toRecordingPlan(sessionId)) + suspend fun start(): AppResult = engine.start(request.toRecordingPlan(sessionId)) - suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(sessionId, reason) + suspend fun stop(reason: RecordingStopReason): AppResult = engine.stop(reason) fun annotate(timestampMillis: Long, text: String): AppResult = androidSources.annotate(timestampMillis, text) fun playAlarm(): AppResult = androidSources.playAlarm() - private fun MeasurementConfig.toRecordingPlan(sessionId: RecordingSessionId): RecordingPlan { + private fun MeasurementLaunchRequest.toRecordingPlan(sessionId: RecordingSessionId): RecordingPlan { val specs = buildList { add(RecordingSourceSpec.Session) - if (sensorIds.isNotEmpty()) add(RecordingSourceSpec.Sensors(sensorIds.toSet(), sensorSamplingPeriod)) + if (sensorIds.isNotEmpty()) add(RecordingSourceSpec.Sensors(sensorIds, sensorSamplingPeriod)) if (includesGps) add(RecordingSourceSpec.Gps(gpsIntervalSeconds, gpsMinDistanceMeters)) if (activityRecognition) add(RecordingSourceSpec.ActivityRecognition(activityRecognitionPeriodSeconds)) if (significantMotion) add(RecordingSourceSpec.SignificantMotion) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt index 72b2d80..b8855e1 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/ServiceControllerFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.sensorservices.handlers.StorageHandler +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import kotlinx.coroutines.CoroutineScope import javax.inject.Inject @@ -12,9 +13,9 @@ internal class ServiceControllerFactory @Inject constructor( private val diagnosticLogger: DiagnosticLogger, private val clock: EpochClock, ) { - fun create(context: Context, config: MeasurementConfig, scope: CoroutineScope) = ServiceController( + fun create(context: Context, request: MeasurementLaunchRequest, scope: CoroutineScope) = ServiceController( context = context, - config = config, + request = request, scope = scope, storage = storage, diagnosticLogger = diagnosticLogger, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt deleted file mode 100644 index 7ff6556..0000000 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionArtifacts.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tomasrepcik.sensorbox.sensorservices.serviceController - -import android.hardware.SensorManager -import android.media.AudioManager -import android.media.ToneGenerator -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage -import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ExtraInfoHandler - -internal class SessionArtifacts( - private val config: MeasurementConfig, - private val storage: MeasurementStorage, - clock: EpochClock, - sensorManager: SensorManager, -) { - private val extraInfo = ExtraInfoHandler(storage, clock, sensorManager) - private var toneGenerator: ToneGenerator? = null - - fun start(): AppResult = storage - .createMeasurementDirectory(config.folderName, config.useInternalStorage) - .onSuccess { extraInfo.start(config) } - - fun annotate(timestampMillis: Long, text: String): AppResult = appResult( - AppErrorCode.MEASUREMENT, - "Add measurement annotation", - ) { - extraInfo.annotate(timestampMillis, text) - } - - fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { - extraInfo.alarmTriggered() - val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } - tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) - } - - fun stop(): AppResult { - val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { - toneGenerator?.release() - toneGenerator = null - } - return listOf(releaseTone, extraInfo.write()) - .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") - } - - private companion object { - const val ALARM_DURATION_MILLIS = 1_000 - } -} diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt index 20275f1..9cc31c0 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SessionRecordingSource.kt @@ -1,15 +1,70 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController +import android.hardware.SensorManager +import android.media.AudioManager +import android.media.ToneGenerator +import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.recording.RecordingStopContext +import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.ExtraInfoHandler +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest +import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats + +internal class SessionRecordingSource( + private val request: MeasurementLaunchRequest, + private val storage: MeasurementStorage, + clock: EpochClock, + sensorManager: SensorManager, +) : RecordingSource { + private val extraInfo = ExtraInfoHandler(storage, clock, sensorManager) + private var toneGenerator: ToneGenerator? = null -internal class SessionRecordingSource(private val artifacts: SessionArtifacts) : RecordingSource { override val type = RecordingSourceType.SESSION - override suspend fun start(spec: RecordingSourceSpec): AppResult = - if (spec is RecordingSourceSpec.Session) artifacts.start() else invalidSpec(type) + override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.Session) { + storage.createMeasurementDirectory(request.folderName, request.useInternalStorage) + .onSuccess { extraInfo.start(request) } + } else { + invalidSpec(type) + } + + fun annotate(timestampMillis: Long, text: String): AppResult = appResult( + AppErrorCode.MEASUREMENT, + "Add measurement annotation", + ) { + extraInfo.annotate(timestampMillis, text) + } + + fun playAlarm(): AppResult = appResult(AppErrorCode.MEASUREMENT, "Play measurement alarm") { + extraInfo.alarmTriggered() + val tone = toneGenerator ?: ToneGenerator(AudioManager.STREAM_ALARM, 100).also { toneGenerator = it } + tone.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, ALARM_DURATION_MILLIS) + } + + fun recordSensorStats(stats: List) { + extraInfo.recordSensorStats(stats) + } + + override suspend fun stop(context: RecordingStopContext): AppResult { + val releaseTone = appResult(AppErrorCode.MEASUREMENT, "Release measurement alarm") { + toneGenerator?.release() + toneGenerator = null + } + val metadataContext = releaseTone.errorOrNull() + ?.let(context::withFailure) + ?: context + return listOf(releaseTone, extraInfo.write(metadataContext)) + .combineAppResults(AppErrorCode.MEASUREMENT, "Close measurement session artifacts") + } - override suspend fun stop(): AppResult = artifacts.stop() + private companion object { + const val ALARM_DURATION_MILLIS = 1_000 + } } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt index 742f5d2..59c3aca 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/serviceController/SignificantMotionRecordingSource.kt @@ -1,24 +1,26 @@ package com.tomasrepcik.sensorbox.sensorservices.serviceController import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.flatMap import com.tomasrepcik.sensorbox.recording.RecordingSource import com.tomasrepcik.sensorbox.recording.RecordingSourceSpec import com.tomasrepcik.sensorbox.recording.RecordingSourceType +import com.tomasrepcik.sensorbox.recording.RecordingStopContext import com.tomasrepcik.sensorbox.sensorservices.handlers.measurements.SignificantMotion +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest internal class SignificantMotionRecordingSource( - private val config: MeasurementConfig, + private val request: MeasurementLaunchRequest, private val measurement: SignificantMotion, ) : RecordingSource { override val type = RecordingSourceType.SIGNIFICANT_MOTION + override val failures = measurement.failures override suspend fun start(spec: RecordingSourceSpec): AppResult = if (spec is RecordingSourceSpec.SignificantMotion) { - measurement.prepare(config.folderName, config.useInternalStorage).flatMap { measurement.start() } + measurement.start(request.folderName, request.useInternalStorage) } else { invalidSpec(type) } - override suspend fun stop(): AppResult = measurement.stop() + override suspend fun stop(context: RecordingStopContext): AppResult = measurement.stop() } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt index 94cd5a0..dfe96d2 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementHostResources.kt @@ -14,7 +14,7 @@ import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.serviceController.MeasurementConfig +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -36,10 +36,10 @@ internal class MeasurementHostResources( } } - fun start(config: MeasurementConfig) { - promoteToForeground(config) - if (config.useWakeLock) acquireWakeLock() - if (config.stopOnLowBattery) registerLowBatteryReceiver() + fun start(request: MeasurementLaunchRequest) { + promoteToForeground(request) + if (request.requiresWakeLock) acquireWakeLock() + if (request.stopOnLowBattery) registerLowBatteryReceiver() } fun scheduleAlarms(offsetsSeconds: List) { @@ -62,16 +62,16 @@ internal class MeasurementHostResources( service.stopForeground(Service.STOP_FOREGROUND_REMOVE) } - private fun promoteToForeground(config: MeasurementConfig) { + private fun promoteToForeground(request: MeasurementLaunchRequest) { val notification = Notify.createRecordingNotification(service) if (Build.VERSION.SDK_INT >= 34) { - service.startForeground(NOTIFICATION_ID, notification, foregroundTypes(config)) + service.startForeground(NOTIFICATION_ID, notification, foregroundTypes(request)) } else { service.startForeground(NOTIFICATION_ID, notification) } } - private fun foregroundTypes(config: MeasurementConfig): Int = if (config.includesGps) { + private fun foregroundTypes(request: MeasurementLaunchRequest): Int = if (request.includesGps) { FOREGROUND_SERVICE_TYPE_HEALTH or FOREGROUND_SERVICE_TYPE_LOCATION } else { FOREGROUND_SERVICE_TYPE_HEALTH diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt index d5fd07e..10354ea 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/MeasurementService.kt @@ -11,7 +11,7 @@ import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.recording.RecordingEvent import com.tomasrepcik.sensorbox.recording.RecordingStopReason -import com.tomasrepcik.sensorbox.sensorservices.serviceController.MeasurementConfig +import com.tomasrepcik.sensorbox.sensorservices.intent.MeasurementLaunchRequest import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceController import com.tomasrepcik.sensorbox.sensorservices.serviceController.ServiceControllerFactory import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionState @@ -40,7 +40,7 @@ class MeasurementService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var controller: ServiceController? = null - private var activeConfig: MeasurementConfig? = null + private var activeRequest: MeasurementLaunchRequest? = null private var eventJob: Job? = null private var isFinishing = false private val hostResources by lazy { @@ -63,25 +63,28 @@ class MeasurementService : Service() { intent.getStringExtra(ANNOTATION_TEXT).orEmpty(), ) - else -> intent?.takeIf { activeConfig == null }?.let(::startRecordingHost) + else -> intent?.takeIf { activeRequest == null }?.let(::startRecordingHost) } return START_NOT_STICKY } private fun startRecordingHost(intent: Intent) { appResult(AppErrorCode.MEASUREMENT, "Start recording foreground host") { - val config = MeasurementConfig.from(intent) - require(config.sessionId.isNotBlank()) { "Recording session ID is missing" } - activeConfig = config + val request = MeasurementLaunchRequest.from(intent) + require(request.sessionId.isNotBlank()) { "Recording session ID is missing" } + activeRequest = request isFinishing = false - hostResources.start(config) - val serviceController = controllerFactory.create(this, config, serviceScope) + hostResources.start(request) + val serviceController = controllerFactory.create(this, request, serviceScope) controller = serviceController observeEngine(serviceController) serviceScope.launch { val result = serviceController.start() - if (result.isFailure && !isFinishing) { - finishRecording(MeasurementStopReason.SOURCE_FAILURE, result) + if (result.isFailure) { + finishRecording( + MeasurementStopReason.SOURCE_FAILURE, + result, + ) } } }.onFailure { error -> @@ -101,11 +104,6 @@ class MeasurementService : Service() { when (event) { is RecordingEvent.RecordingStarted -> onRecordingStarted(event) - is RecordingEvent.RecordingStartRejected -> finishRecording( - MeasurementStopReason.SOURCE_FAILURE, - AppResult.failure(event.error), - ) - is RecordingEvent.RecordingStopped -> finishRecording( event.reason.toMeasurementReason(), event.result, @@ -116,32 +114,31 @@ class MeasurementService : Service() { } private fun onRecordingStarted(event: RecordingEvent.RecordingStarted) { - val config = activeConfig ?: return + val request = activeRequest ?: return sessionStore.markRunning( MeasurementSessionState.Running( sessionId = event.sessionId.value, - folderName = config.folderName, + folderName = request.folderName, startedAtElapsedRealtime = SystemClock.elapsedRealtime(), - sensorIds = config.sensorIds.toList(), - includesGps = config.includesGps, + sensorIds = request.sensorIds.toList(), + includesGps = request.includesGps, ), ) - hostResources.scheduleAlarms(config.alarmOffsetsSeconds) + hostResources.scheduleAlarms(request.alarmOffsetsSeconds) } private fun requestStop(reason: RecordingStopReason) { if (isFinishing) return sessionStore.markStopping() serviceScope.launch { - val result = controller?.stop(reason) ?: AppResult.success(Unit) - if (result.isFailure && !isFinishing) finishRecording(reason.toMeasurementReason(), result) + controller?.stop(reason) } } private suspend fun finishRecording(reason: MeasurementStopReason, engineResult: AppResult) { if (isFinishing) return isFinishing = true - val sessionId = activeConfig?.sessionId.orEmpty() + val sessionId = activeRequest?.sessionId.orEmpty() val hostResult = finishHost() val result = listOf(engineResult, hostResult) .combineAppResults(AppErrorCode.MEASUREMENT, "Finish recording foreground host") @@ -153,7 +150,7 @@ class MeasurementService : Service() { private fun finishHost(): AppResult { val results = mutableListOf>() results += hostResources.release() - activeConfig = null + activeRequest = null controller = null results += appResult(AppErrorCode.MEASUREMENT, "Publish idle measurement state") { sessionStore.markIdle() @@ -168,8 +165,8 @@ class MeasurementService : Service() { } override fun onDestroy() { - val config = activeConfig - if (config != null && !isFinishing) { + val request = activeRequest + if (request != null && !isFinishing) { isFinishing = true val cleanup = runBlocking(Dispatchers.IO) { controller?.stop(RecordingStopReason.PLATFORM_DESTROYED) ?: AppResult.success(Unit) @@ -177,7 +174,7 @@ class MeasurementService : Service() { val resources = hostResources.release() sessionStore.markIdle() sessionStore.publishStopped( - config.sessionId, + request.sessionId, MeasurementStopReason.SERVICE_DESTROYED, listOf(cleanup, resources).combineAppResults( AppErrorCode.MEASUREMENT, diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt index 9872343..15a5440 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/services/Notify.kt @@ -27,10 +27,21 @@ object Notify { .setSmallIcon(R.drawable.ic_graph) .setOngoing(true) .setCategory(Notification.CATEGORY_SERVICE) + .setContentIntent(createOpenAppAction(context)) .addAction(R.drawable.ic_stop, context.getString(R.string.text_stop), stopAction) .build() } + private fun createOpenAppAction(context: Context): PendingIntent? { + val openAppIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + return PendingIntent.getActivity( + context, + OPEN_APP_REQUEST_CODE, + openAppIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + private fun createChannel(context: Context) { if (Build.VERSION.SDK_INT < 26) return val channel = NotificationChannel( @@ -42,5 +53,6 @@ object Notify { } private const val CHANNEL_ID = "measurement" + private const val OPEN_APP_REQUEST_CODE = 10 private const val STOP_REQUEST_CODE = 20 } diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/MeasurementSessionStore.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/MeasurementSessionStore.kt index 4b72b02..f93fbb5 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/MeasurementSessionStore.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/session/MeasurementSessionStore.kt @@ -12,9 +12,9 @@ import javax.inject.Singleton @Singleton class MeasurementSessionStore @Inject constructor() { private val mutableState = MutableStateFlow(MeasurementSessionState.Idle) - private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) val state: StateFlow = mutableState.asStateFlow() - val events: SharedFlow = mutableEvents + val events: SharedFlow = mutableEvents fun markRunning(state: MeasurementSessionState.Running) { mutableState.value = state @@ -29,7 +29,7 @@ class MeasurementSessionStore @Inject constructor() { } fun publishStopped(sessionId: String, reason: MeasurementStopReason, result: AppResult) { - mutableEvents.tryEmit(MeasurementSessionEvent.Stopped(sessionId, reason, result)) + mutableEvents.tryEmit(MeasurementStopped(sessionId, reason, result)) } private companion object { @@ -45,7 +45,4 @@ enum class MeasurementStopReason { SERVICE_DESTROYED, } -sealed interface MeasurementSessionEvent { - data class Stopped(val sessionId: String, val reason: MeasurementStopReason, val result: AppResult) : - MeasurementSessionEvent -} +data class MeasurementStopped(val sessionId: String, val reason: MeasurementStopReason, val result: AppResult) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt index 7dfed0f..8e2a419 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolder.kt @@ -11,61 +11,86 @@ import com.tomasrepcik.sensorbox.core.error.appResult import com.tomasrepcik.sensorbox.core.error.combineAppResults import com.tomasrepcik.sensorbox.core.error.suspendAppResult import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.core.time.EpochClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel -import java.io.IOException +import kotlinx.serialization.Serializable import java.io.OutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference -class SensorHolder( +internal class SensorHolder( val spec: SensorSpec, outputStream: OutputStream, private val diagnosticLogger: DiagnosticLogger, - private val clock: EpochClock, + private val onFailure: (AppError) -> Unit, ) : SensorEventListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val samples = Channel(capacity = Channel.BUFFERED) + private val samples = Channel(capacity = SAMPLE_BUFFER_CAPACITY) private val writer = outputStream.bufferedWriter() - private val writerJob = scope.async { - try { - writer.append(spec.header) - writer.flush() - for (sample in samples) { - writer.appendLine(sample.toCsv(spec.axisCount)) - writer.flush() - } - } catch (error: IOException) { - throw reportWriterFailure(error) - } catch (error: IllegalStateException) { - throw reportWriterFailure(error) - } - } + private val acceptedSamples = AtomicLong() + private val writtenSamples = AtomicLong() + private val droppedSamples = AtomicLong() + private val failure = AtomicReference() + + private val writerJob = scope.async { writeSamples() } override fun onSensorChanged(event: SensorEvent) { - val result = samples.trySend( + record( SensorSample( sensorTimestampNanos = event.timestamp, - unixTimestampMillis = clock.nowMillis(), values = event.values.copyOf(spec.axisCount), accuracy = event.accuracy, ), ) - if (result.isFailure && result.exceptionOrNull() == null) { - samples.close(IllegalStateException("Sensor sample buffer is full")) + } + + internal fun record(sample: SensorSample) { + val result = samples.trySend(sample) + if (result.isSuccess) { + acceptedSamples.incrementAndGet() + } else if (result.exceptionOrNull() == null) { + droppedSamples.incrementAndGet() + val cause = IllegalStateException("Sensor sample buffer is full") + val error = AppError.from(AppErrorCode.MEASUREMENT, "Buffer ${spec.fileName}", cause) + reportFailure(error) + samples.close(cause) + } else if (failure.get() != null) { + droppedSamples.incrementAndGet() } } 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 + @Suppress("TooGenericExceptionCaught") + private suspend fun writeSamples() { + try { + writer.append(spec.header) + writer.flush() + var lastFlushAt = 0L + for (sample in samples) { + writer.appendLine(sample.toCsv()) + writtenSamples.incrementAndGet() + if (sample.sensorTimestampNanos - lastFlushAt >= FLUSH_INTERVAL_NANOS) { + writer.flush() + lastFlushAt = sample.sensorTimestampNanos + } + } + writer.flush() + } catch (error: Throwable) { + reportFailure(AppError.from(AppErrorCode.STORAGE, "Write ${spec.fileName}", error)) + samples.close(error) + throw error + } + } + + private fun reportFailure(error: AppError) { + if (!failure.compareAndSet(null, error)) return + diagnosticLogger.record(error.toDiagnosticEvent()) + onFailure(error) } suspend fun close(): AppResult { @@ -77,18 +102,34 @@ class SensorHolder( scope.cancel() return results.combineAppResults(AppErrorCode.STORAGE, "Close ${spec.fileName}") } + + fun stats(): SensorFileStats = SensorFileStats( + fileName = spec.fileName, + acceptedSamples = acceptedSamples.get(), + writtenSamples = writtenSamples.get(), + droppedSamples = droppedSamples.get(), + failureOperation = failure.get()?.operation, + ) + + private companion object { + const val SAMPLE_BUFFER_CAPACITY = 8_192 + const val FLUSH_INTERVAL_NANOS = 1_000_000_000L + } } -class SensorSample( - val sensorTimestampNanos: Long, - val unixTimestampMillis: Long, - val values: FloatArray, - val accuracy: Int, -) { - fun toCsv(axisCount: Int): String = buildString { +internal data class SensorSample(val sensorTimestampNanos: Long, val values: FloatArray, val accuracy: Int) { + fun toCsv(): String = buildString { append(sensorTimestampNanos).append(';') - append(unixTimestampMillis).append(';') - values.take(axisCount).forEach { value -> append(value).append(';') } + values.forEach { value -> append(value).append(';') } append(accuracy) } } + +@Serializable +internal data class SensorFileStats( + val fileName: String, + val acceptedSamples: Long, + val writtenSamples: Long, + val droppedSamples: Long, + val failureOperation: String?, +) diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt index 643beca..1488b85 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpec.kt @@ -3,41 +3,41 @@ package com.tomasrepcik.sensorbox.sensorservices.types import android.hardware.Sensor enum class SensorSpec(val type: Int, val axisCount: Int, val fileName: String, val header: String) { - ACCELEROMETER(Sensor.TYPE_ACCELEROMETER, 3, "accelerometer.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), + ACCELEROMETER(Sensor.TYPE_ACCELEROMETER, 3, "accelerometer.csv", "t_sensor;x;y;z;accuracy\n"), AMBIENT_TEMPERATURE( Sensor.TYPE_AMBIENT_TEMPERATURE, 1, "ambient_temperature.csv", - "t_sensor;t_unix;value;accuracy\n", + "t_sensor;value;accuracy\n", ), - GRAVITY(Sensor.TYPE_GRAVITY, 3, "gravity.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), - GYROSCOPE(Sensor.TYPE_GYROSCOPE, 3, "gyroscope.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), - LIGHT(Sensor.TYPE_LIGHT, 1, "light.csv", "t_sensor;t_unix;value;accuracy\n"), + GRAVITY(Sensor.TYPE_GRAVITY, 3, "gravity.csv", "t_sensor;x;y;z;accuracy\n"), + GYROSCOPE(Sensor.TYPE_GYROSCOPE, 3, "gyroscope.csv", "t_sensor;x;y;z;accuracy\n"), + LIGHT(Sensor.TYPE_LIGHT, 1, "light.csv", "t_sensor;value;accuracy\n"), LINEAR_ACCELERATION( Sensor.TYPE_LINEAR_ACCELERATION, 3, "linear_acceleration.csv", - "t_sensor;t_unix;x;y;z;accuracy\n", + "t_sensor;x;y;z;accuracy\n", ), - MAGNETIC_FIELD(Sensor.TYPE_MAGNETIC_FIELD, 3, "magnetic_field.csv", "t_sensor;t_unix;x;y;z;accuracy\n"), - PRESSURE(Sensor.TYPE_PRESSURE, 1, "pressure.csv", "t_sensor;t_unix;value;accuracy\n"), - PROXIMITY(Sensor.TYPE_PROXIMITY, 1, "proximity.csv", "t_sensor;t_unix;value;accuracy\n"), + MAGNETIC_FIELD(Sensor.TYPE_MAGNETIC_FIELD, 3, "magnetic_field.csv", "t_sensor;x;y;z;accuracy\n"), + PRESSURE(Sensor.TYPE_PRESSURE, 1, "pressure.csv", "t_sensor;value;accuracy\n"), + PROXIMITY(Sensor.TYPE_PROXIMITY, 1, "proximity.csv", "t_sensor;value;accuracy\n"), RELATIVE_HUMIDITY( Sensor.TYPE_RELATIVE_HUMIDITY, 1, "relative_humidity.csv", - "t_sensor;t_unix;value;accuracy\n", + "t_sensor;value;accuracy\n", ), - ROTATION_VECTOR(Sensor.TYPE_ROTATION_VECTOR, 4, "rotation_vector.csv", "t_sensor;t_unix;x;y;z;scalar;accuracy\n"), - STEP_COUNTER(Sensor.TYPE_STEP_COUNTER, 1, "step_counter.csv", "t_sensor;t_unix;steps;accuracy\n"), - STEP_DETECTOR(Sensor.TYPE_STEP_DETECTOR, 1, "step_detector.csv", "t_sensor;t_unix;step;accuracy\n"), + ROTATION_VECTOR(Sensor.TYPE_ROTATION_VECTOR, 4, "rotation_vector.csv", "t_sensor;x;y;z;scalar;accuracy\n"), + STEP_COUNTER(Sensor.TYPE_STEP_COUNTER, 1, "step_counter.csv", "t_sensor;steps;accuracy\n"), + STEP_DETECTOR(Sensor.TYPE_STEP_DETECTOR, 1, "step_detector.csv", "t_sensor;step;accuracy\n"), // Kept discoverable in the sensor catalogue, but recorded by SignificantMotion's trigger listener. SIGNIFICANT_MOTION( Sensor.TYPE_SIGNIFICANT_MOTION, 1, "significant_motion.csv", - "t_sensor;t_unix;event;accuracy\n", + "t_sensor;event;accuracy\n", ), ; diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt index f915685..3520139 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/ActivityRecognitionMeasurementTest.kt @@ -4,6 +4,7 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.sensorservices.handlers.MeasurementStorage +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -12,34 +13,44 @@ 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")) - } + fun `Given fake storage and platform When activity measurement runs Then lifecycle has no Android types`() = + runBlocking { + val storage = FakeMeasurementStorage() + val platform = FakeActivityRecognitionPlatform() + val measurement = ActivityRecognitionMeasurement(storage, platform) + + assertTrue( + measurement.start( + folderName = "session", + useInternalStorage = true, + periodSeconds = 15, + ).isSuccess, + ) + platform.emitUpdate(ActivityUpdate(123L, listOf(1, 2, 3, 4, 5, 6, 7, 8))) + platform.emitTransitions(listOf(ActivityTransitionSample(456L, 2, 1))) + val stopped = 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`() { + fun `Given transition storage failure When started Then platform initialization does not run`() = runBlocking { val storage = FakeMeasurementStorage(failOnCall = 2) val platform = FakeActivityRecognitionPlatform() - val measurement = ActivityRecognitionMeasurement(15, storage, platform) + val measurement = ActivityRecognitionMeasurement(storage, platform) - val result = measurement.prepare("session", useInternalStorage = false) + val result = measurement.start( + folderName = "session", + useInternalStorage = false, + periodSeconds = 15, + ) assertTrue(result.isFailure) - assertEquals(0, platform.prepareCalls) + assertEquals(0, platform.startCalls) assertEquals(2, storage.openCalls) } @@ -68,7 +79,7 @@ class ActivityRecognitionMeasurementTest { } private class FakeActivityRecognitionPlatform : ActivityRecognitionPlatform { - var prepareCalls = 0 + var startCalls = 0 private set var stopCalls = 0 private set @@ -77,22 +88,19 @@ class ActivityRecognitionMeasurementTest { private var updateCallback: ((ActivityUpdate) -> Unit)? = null private var transitionCallback: ((List) -> Unit)? = null - override fun prepare( + override suspend fun start( + periodSeconds: Int, onUpdate: (ActivityUpdate) -> Unit, onTransitions: (List) -> Unit, ): AppResult { - prepareCalls += 1 + startCalls += 1 + startedPeriodSeconds = periodSeconds updateCallback = onUpdate transitionCallback = onTransitions return AppResult.success(Unit) } - override fun start(periodSeconds: Int): AppResult { - startedPeriodSeconds = periodSeconds - return AppResult.success(Unit) - } - - override fun stop(): AppResult { + override suspend fun stop(): AppResult { stopCalls += 1 return AppResult.success(Unit) } diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt index bdc1f2c..dabd4b1 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementMetadataSerializationTest.kt @@ -1,5 +1,6 @@ package com.tomasrepcik.sensorbox.sensorservices.handlers.measurements +import com.tomasrepcik.sensorbox.sensorservices.types.SensorFileStats import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.boolean @@ -15,28 +16,14 @@ 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 = "RECORDING", - 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 + val json = Json.parseToJsonElement(Json.encodeToString(metadataFixture())).jsonObject assertEquals( setOf( "millis", "nanos", + "endedAtMillis", + "endedAtNanos", "type", "date", "folder", @@ -46,14 +33,28 @@ class MeasurementMetadataSerializationTest { "alarms", "configuredAlarmOffsetsSeconds", "durationMillis", + "actualDurationMillis", + "stopReason", + "failureOperation", + "failureMessage", + "failures", + "wakeLockEnabled", + "sensorFiles", "activityRecognition", "significantMotion", ), json.keys, ) assertEquals(100L, json.getValue("millis").jsonPrimitive.long) + assertEquals(200L, json.getValue("nanos").jsonPrimitive.long) + assertEquals(60_100L, json.getValue("endedAtMillis").jsonPrimitive.long) assertEquals("RECORDING", json.getValue("type").jsonPrimitive.content) assertEquals(60_000L, json.getValue("durationMillis").jsonPrimitive.long) + assertEquals(60_000L, json.getValue("actualDurationMillis").jsonPrimitive.long) + assertEquals("USER_REQUEST", json.getValue("stopReason").jsonPrimitive.content) + val failure = json.getValue("failures").jsonArray.single().jsonObject + assertEquals("STORAGE", failure.getValue("code").jsonPrimitive.content) + assertEquals("Write accelerometer.csv", failure.getValue("operation").jsonPrimitive.content) 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 @@ -63,4 +64,45 @@ class MeasurementMetadataSerializationTest { assertEquals(9.81f, range.getValue("range").jsonPrimitive.float) assertEquals(true, json.getValue("activityRecognition").jsonPrimitive.boolean) } + + private fun metadataFixture() = MeasurementMetadata( + millis = 100L, + nanos = 200L, + endedAtMillis = 60_100L, + endedAtNanos = 60_000_000_200L, + type = "RECORDING", + 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, + actualDurationMillis = 60_000L, + stopReason = "USER_REQUEST", + failureOperation = "Write accelerometer.csv", + failureMessage = "Write accelerometer.csv failed with IOException", + failures = listOf( + MeasurementFailure( + code = "STORAGE", + operation = "Write accelerometer.csv", + message = "Write accelerometer.csv failed with IOException", + cause = "IOException: disk full", + context = emptyMap(), + ), + ), + wakeLockEnabled = true, + sensorFiles = listOf( + SensorFileStats( + fileName = "accelerometer.csv", + acceptedSamples = 10L, + writtenSamples = 10L, + droppedSamples = 0L, + failureOperation = null, + ), + ), + activityRecognition = true, + significantMotion = false, + ) } diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementPreparationTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementPreparationTest.kt index 81ec2f2..1f5eadd 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementPreparationTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/handlers/measurements/MeasurementPreparationTest.kt @@ -15,11 +15,11 @@ import java.io.OutputStream class MeasurementPreparationTest { @Test - fun `Given first sensor file failure When sensors prepare Then later sensor files are not opened`() { + fun `Given first sensor file failure When sensors start Then later sensor files are not opened`() { val storage = FailingMeasurementStorage() - val measurement = SensorMeasurement(storage, DiagnosticLogger { }, EpochClock { 1L }) + val measurement = SensorMeasurement(storage, DiagnosticLogger { }) - val result = measurement.prepare( + val result = measurement.start( folderName = "session", useInternalStorage = true, sensorTypes = setOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE), @@ -31,11 +31,11 @@ class MeasurementPreparationTest { } @Test - fun `Given GPS file failure When GPS prepares Then no output work follows`() { + fun `Given GPS file failure When GPS starts Then no output work follows`() { val storage = FailingMeasurementStorage() val measurement = GPSMeasurement(GPSHandler(), storage, EpochClock { 1L }) - val result = measurement.prepare( + val result = measurement.start( folderName = "session", useInternalStorage = true, intervalSeconds = 10, diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt index a6c0b3c..224ae69 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorHolderErrorTest.kt @@ -5,11 +5,11 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.DiagnosticEvent import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.time.EpochClock import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.ByteArrayOutputStream import java.io.IOException import java.io.OutputStream @@ -18,7 +18,8 @@ class SensorHolderErrorTest { fun `Given a failed output stream When holder closes Then storage AppError is returned`() = runBlocking { val spec = checkNotNull(SensorSpec.fromType(Sensor.TYPE_ACCELEROMETER)) val logger = RecordingDiagnosticLogger() - val holder = SensorHolder(spec, FailingOutputStream(), logger, EpochClock { 123L }) + val failures = mutableListOf() + val holder = SensorHolder(spec, FailingOutputStream(), logger, failures::add) val result = holder.close() @@ -26,6 +27,38 @@ class SensorHolderErrorTest { assertTrue(error is AppError) assertEquals(AppErrorCode.STORAGE, (error as AppError).code) assertEquals("Write ${spec.fileName}", logger.events.single().operation) + assertEquals("Write ${spec.fileName}", failures.single().operation) + } + + @Test + fun `Given a large sensor burst When holder closes Then every sample is written in order`() = runBlocking { + // Given + val spec = checkNotNull(SensorSpec.fromType(Sensor.TYPE_ACCELEROMETER)) + val output = ByteArrayOutputStream() + val failures = mutableListOf() + val holder = SensorHolder(spec, output, DiagnosticLogger { }, failures::add) + + // When + repeat(SAMPLE_COUNT) { index -> + holder.record( + SensorSample( + sensorTimestampNanos = index.toLong(), + values = floatArrayOf(index.toFloat(), 2f, 3f), + accuracy = 3, + ), + ) + } + val result = holder.close() + + // Then + val lines = output.toString(Charsets.UTF_8.name()).lineSequence().filter(String::isNotBlank).toList() + assertTrue(result.isSuccess) + assertEquals(SAMPLE_COUNT + 1, lines.size) + assertEquals("t_sensor;x;y;z;accuracy", lines.first()) + assertEquals("0;0.0;2.0;3.0;3", lines[1]) + assertEquals("${SAMPLE_COUNT - 1};${SAMPLE_COUNT - 1}.0;2.0;3.0;3", lines.last()) + assertEquals(SAMPLE_COUNT.toLong(), holder.stats().writtenSamples) + assertTrue(failures.isEmpty()) } private class FailingOutputStream : OutputStream() { @@ -39,4 +72,8 @@ class SensorHolderErrorTest { events += event } } + + private companion object { + const val SAMPLE_COUNT = 5_000 + } } diff --git a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt index 9a847ca..b4a71d2 100644 --- a/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt +++ b/sensorservices/src/test/java/com/tomasrepcik/sensorbox/sensorservices/types/SensorSpecTest.kt @@ -25,6 +25,6 @@ class SensorSpecTest { } private companion object { - const val METADATA_COLUMNS = 3 + const val METADATA_COLUMNS = 2 } } diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt index e87ee76..26749f0 100644 --- a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/emulator/WearRecordingEmulatorFixture.kt @@ -20,8 +20,8 @@ import java.io.File internal data class WearRecordedSensor( val type: Int, val fileName: String, - val header: String = "t_sensor;t_unix;x;y;z;accuracy", - val columnCount: Int = 6, + val header: String = "t_sensor;x;y;z;accuracy", + val columnCount: Int = 5, ) internal data class WearRecordingScenario( @@ -244,6 +244,8 @@ internal class WearRecordingEmulatorFixture(private val context: Context) { private fun assertSessionMetadata() { assertEquals(scenario.name, metadata.getString("folder")) + assertTrue(metadata.getLong("millis") >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS) + assertTrue(metadata.getLong("nanos") > 0L) assertEquals(scenario.durationMillis.coerceAtLeast(0), metadata.getLong("durationMillis")) val recordedTypes = metadata.getJSONArray("ranges").let { ranges -> buildSet { @@ -285,13 +287,7 @@ internal class WearRecordingEmulatorFixture(private val context: Context) { columns } val sensorTimestamps = samples.map { it[0].toLong() } - val unixTimestamps = samples.map { it[1].toLong() } assertEquals(sensorTimestamps.sorted(), sensorTimestamps) - assertEquals(unixTimestamps.sorted(), unixTimestamps) - assertTrue( - "A Wear sample was written before recording started: $unixTimestamps", - unixTimestamps.all { it >= scheduledStartMillis - TIMESTAMP_TOLERANCE_MILLIS }, - ) } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt index 8965d14..2a99b1b 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/communication/WearRecordingSessionObserver.kt @@ -2,9 +2,9 @@ package com.tomasrepcik.sensorbox.communication import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionEvent import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementSessionStore import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopReason +import com.tomasrepcik.sensorbox.sensorservices.session.MeasurementStopped import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,13 +27,11 @@ class WearRecordingSessionObserver @Inject constructor( fun start() { if (!started.compareAndSet(false, true)) return scope.launch { - sessionStore.events.collect { event -> - if (event is MeasurementSessionEvent.Stopped) onStopped(event) - } + sessionStore.events.collect(::onStopped) } } - private suspend fun onStopped(event: MeasurementSessionEvent.Stopped) { + private suspend fun onStopped(event: MeasurementStopped) { event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } val reason = event.reason.toAutomaticWearReason() ?: return commandHandler.onAutomaticStop(reason).onFailure { error ->