diff --git a/build.gradle.kts b/build.gradle.kts index c953e63..10f9403 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.oss.licenses) apply false } diff --git a/core-common/build.gradle.kts b/core-common/build.gradle.kts new file mode 100644 index 0000000..de002f4 --- /dev/null +++ b/core-common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.detekt) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(libs.coroutines.core) + testImplementation(libs.junit) +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt new file mode 100644 index 0000000..c8d4ac3 --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppError.kt @@ -0,0 +1,45 @@ +package com.motionapps.sensorbox.core.error + +data class AppError( + val code: AppErrorCode, + val operation: String, + val diagnosticMessage: String, + val cause: Throwable? = null, + val context: Map = emptyMap(), + val isRetryable: Boolean = false, +) { + constructor(code: AppErrorCode, operation: String) : this( + code = code, + operation = operation, + diagnosticMessage = "$operation failed", + ) + + fun within(parentOperation: String, fallbackCode: AppErrorCode = code): AppError = copy( + code = if (code == AppErrorCode.UNKNOWN) fallbackCode else code, + operation = parentOperation, + diagnosticMessage = "$parentOperation failed during $operation", + context = context + ("failedOperation" to operation), + ) + + companion object { + fun from(code: AppErrorCode, operation: String, cause: Throwable): AppError = AppError( + code = code, + operation = operation, + diagnosticMessage = "$operation failed with ${cause::class.java.simpleName}", + cause = cause, + ) + } +} + +enum class AppErrorCode { + CONNECTIVITY, + EXTERNAL_ACTION, + MEASUREMENT, + PERMISSION, + PREFERENCES, + STORAGE, + VALIDATION, + TIMEOUT, + CONFLICT, + UNKNOWN, +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt new file mode 100644 index 0000000..86a4eaf --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/AppResult.kt @@ -0,0 +1,123 @@ +package com.motionapps.sensorbox.core.error + +import kotlinx.coroutines.CancellationException + +sealed interface AppResult { + val isSuccess: Boolean + get() = this is Success + + val isFailure: Boolean + get() = this is Failure + + fun getOrNull(): T? = when (this) { + is Success -> value + is Failure -> null + } + + fun errorOrNull(): AppError? = when (this) { + is Success -> null + is Failure -> error + } + + fun fold(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) { + is Success -> onSuccess(value) + is Failure -> onFailure(error) + } + + fun map(transform: (T) -> R): AppResult = when (this) { + is Success -> success(transform(value)) + is Failure -> this + } + + fun getOrElse(onFailure: (AppError) -> @UnsafeVariance T): T = when (this) { + is Success -> value + is Failure -> onFailure(error) + } + + fun getOrDefault(defaultValue: @UnsafeVariance T): T = when (this) { + is Success -> value + is Failure -> defaultValue + } + + fun getOrThrow(): T = when (this) { + is Success -> value + is Failure -> throw IllegalStateException(error.diagnosticMessage, error.cause) + } + + fun onSuccess(action: (T) -> Unit): AppResult = apply { + if (this is Success) action(value) + } + + fun onFailure(action: (AppError) -> Unit): AppResult = apply { + if (this is Failure) action(error) + } + + data class Success(val value: T) : AppResult + + data class Failure(val error: AppError) : AppResult + + companion object { + fun success(value: T): AppResult = Success(value) + + fun failure(error: AppError): AppResult = Failure(error) + } +} + +inline fun AppResult.flatMap(transform: (T) -> AppResult): AppResult = when (this) { + is AppResult.Success -> transform(value) + is AppResult.Failure -> this +} + +suspend inline fun AppResult.suspendFlatMap( + crossinline transform: suspend (T) -> AppResult, +): AppResult = when (this) { + is AppResult.Success -> transform(value) + is AppResult.Failure -> this +} + +@Suppress("TooGenericExceptionCaught") +inline fun appResult(code: AppErrorCode, operation: String, block: () -> T): AppResult = try { + AppResult.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + AppResult.failure(AppError.from(code, operation, error)) +} + +@Suppress("TooGenericExceptionCaught") +suspend inline fun suspendAppResult( + code: AppErrorCode, + operation: String, + crossinline block: suspend () -> T, +): AppResult = try { + AppResult.success(block()) +} catch (error: CancellationException) { + throw error +} catch (error: Throwable) { + AppResult.failure(AppError.from(code, operation, error)) +} + +fun AppResult.withAppError(code: AppErrorCode, operation: String): AppResult = when (this) { + is AppResult.Success -> this + is AppResult.Failure -> AppResult.failure(error.within(operation, code)) +} + +fun Iterable>.combineAppResults(code: AppErrorCode, operation: String): AppResult { + val errors = mapNotNull(AppResult<*>::errorOrNull) + if (errors.isEmpty()) return AppResult.success(Unit) + val causes = errors.mapNotNull(AppError::cause) + val firstCause = causes.firstOrNull() + causes.drop(1).forEach { cause -> firstCause?.addSuppressed(cause) } + return AppResult.failure( + AppError( + code = errors.first().code.takeUnless { it == AppErrorCode.UNKNOWN } ?: code, + operation = operation, + diagnosticMessage = "$operation failed in ${errors.size} operation(s)", + cause = firstCause, + context = mapOf( + "failureCount" to errors.size.toString(), + "failedOperations" to errors.joinToString(",") { it.operation }, + ), + ), + ) +} diff --git a/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt new file mode 100644 index 0000000..338d26a --- /dev/null +++ b/core-common/src/main/kotlin/com/motionapps/sensorbox/core/error/Diagnostics.kt @@ -0,0 +1,41 @@ +package com.motionapps.sensorbox.core.error + +import java.io.File + +enum class DiagnosticSeverity { + INFO, + WARNING, + ERROR, + FATAL, +} + +data class DiagnosticEvent( + val severity: DiagnosticSeverity, + val code: AppErrorCode, + val operation: String, + val diagnosticMessage: String, + val cause: Throwable? = null, + val context: Map = emptyMap(), +) + +fun AppError.toDiagnosticEvent(severity: DiagnosticSeverity = DiagnosticSeverity.ERROR): DiagnosticEvent = + DiagnosticEvent( + severity = severity, + code = code, + operation = operation, + diagnosticMessage = diagnosticMessage, + cause = cause, + context = context, + ) + +fun interface DiagnosticLogger { + fun record(event: DiagnosticEvent) +} + +interface DiagnosticsStore { + fun readText(): AppResult + + fun exportFile(): AppResult + + fun clear(): AppResult +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb823c2..ef8e8ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,4 +70,5 @@ detekt = { id = "dev.detekt", version.ref = "detekt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } diff --git a/recording-core/build.gradle.kts b/recording-core/build.gradle.kts new file mode 100644 index 0000000..0c54959 --- /dev/null +++ b/recording-core/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.detekt) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":core-common")) + implementation(libs.coroutines.core) + + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt new file mode 100644 index 0000000..2a8fb45 --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingEngine.kt @@ -0,0 +1,226 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.combineAppResults +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class RecordingEngine( + sources: List, + private val scope: CoroutineScope, + private val clock: RecordingClock, + private val delay: RecordingDelay, +) { + private val sourceByType = sources.associateBy(RecordingSource::type) + private val mutex = Mutex() + private val mutableState = MutableStateFlow(RecordingSessionState.Idle) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) + private var activeSources: List = emptyList() + private var committingSessionId: RecordingSessionId? = null + private var durationJob: Job? = null + private var lastCompletedSessionId: RecordingSessionId? = null + private var lastStopResult: AppResult = AppResult.success(Unit) + + val state: StateFlow = mutableState.asStateFlow() + val events: SharedFlow = mutableEvents.asSharedFlow() + + suspend fun prepare(plan: RecordingPlan): AppResult = mutex.withLock { + if (mutableState.value !is RecordingSessionState.Idle) { + return@withLock conflict("Prepare recording", plan.sessionId) + } + val validation = validate(plan) + if (validation is AppResult.Failure) return@withLock reject(plan.sessionId, validation.error) + + mutableState.value = RecordingSessionState.Preparing(plan) + val prepared = mutableListOf() + for (spec in plan.sources.sortedBy { it.type.ordinal }) { + val source = checkNotNull(sourceByType[spec.type]) + when (val result = source.prepare(spec)) { + is AppResult.Success -> prepared += source + + is AppResult.Failure -> { + val sourcesToClean = prepared + source + activeSources = sourcesToClean + return@withLock rejectWithCleanup(plan.sessionId, result.error, sourcesToClean) + } + } + } + activeSources = prepared + mutableState.value = RecordingSessionState.Prepared(plan) + AppResult.success(Unit) + } + + suspend fun commit(sessionId: RecordingSessionId): AppResult = when (val claim = claimCommit(sessionId)) { + is AppResult.Failure -> claim + + is AppResult.Success -> claim.value?.let { plan -> + delay.pause((plan.startAtEpochMillis - clock.epochMillis()).coerceAtLeast(0L)) + startPreparedSources(sessionId, plan) + } ?: AppResult.success(Unit) + } + + private suspend fun claimCommit(sessionId: RecordingSessionId): AppResult = mutex.withLock { + when (val current = mutableState.value) { + is RecordingSessionState.Running -> if (current.plan.sessionId == sessionId) { + AppResult.success(null) + } else { + conflict("Commit recording", sessionId) + } + + is RecordingSessionState.Prepared -> claimPreparedCommit(current, sessionId) + + else -> conflict("Commit recording", sessionId) + } + } + + private fun claimPreparedCommit( + current: RecordingSessionState.Prepared, + sessionId: RecordingSessionId, + ): AppResult = when { + current.plan.sessionId != sessionId -> conflict("Commit recording", sessionId) + + committingSessionId == sessionId -> AppResult.success(null) + + else -> { + committingSessionId = sessionId + AppResult.success(current.plan) + } + } + + private suspend fun startPreparedSources(sessionId: RecordingSessionId, plan: RecordingPlan): AppResult = + mutex.withLock { + val prepared = mutableState.value as? RecordingSessionState.Prepared + if (prepared?.plan?.sessionId != sessionId) { + return@withLock conflict("Commit recording", sessionId) + } + + for (source in activeSources) { + when (val result = source.start()) { + is AppResult.Success -> Unit + + is AppResult.Failure -> { + committingSessionId = null + return@withLock rejectWithCleanup(sessionId, result.error, activeSources) + } + } + } + committingSessionId = null + val startedAt = clock.epochMillis() + mutableState.value = RecordingSessionState.Running(plan, startedAt) + mutableEvents.tryEmit(RecordingEvent.RecordingStarted(sessionId, startedAt)) + scheduleDurationStop(plan) + AppResult.success(Unit) + } + + suspend fun abort(sessionId: RecordingSessionId): AppResult = + stop(sessionId, RecordingStopReason.PAIRED_ABORT) + + suspend fun stop(sessionId: RecordingSessionId, reason: RecordingStopReason): AppResult = mutex.withLock { + val current = mutableState.value + if (current is RecordingSessionState.Idle) { + return@withLock if (lastCompletedSessionId == sessionId) { + lastStopResult + } else { + conflict("Stop recording", sessionId) + } + } + if (current.sessionId() != sessionId) return@withLock conflict("Stop recording", sessionId) + + mutableState.value = RecordingSessionState.Stopping(sessionId, reason) + val currentJob = currentCoroutineContext()[Job] + durationJob?.takeIf { it !== currentJob }?.cancel() + durationJob = null + committingSessionId = null + val results = activeSources.asReversed().map { it.stop() } + activeSources = emptyList() + val result = results.combineAppResults(AppErrorCode.MEASUREMENT, "Stop recording sources") + lastCompletedSessionId = sessionId + lastStopResult = result + mutableState.value = RecordingSessionState.Idle + mutableEvents.tryEmit(RecordingEvent.RecordingStopped(sessionId, reason, result)) + result + } + + private suspend fun rejectWithCleanup( + sessionId: RecordingSessionId, + startError: AppError, + sources: List, + ): AppResult { + val cleanup = sources.asReversed().map { it.stop() } + activeSources = emptyList() + val result = (listOf(AppResult.failure(startError)) + cleanup).combineAppResults( + AppErrorCode.MEASUREMENT, + "Reject recording start", + ) + return reject(sessionId, checkNotNull(result.errorOrNull())) + } + + private fun reject(sessionId: RecordingSessionId, error: AppError): AppResult { + committingSessionId = null + mutableState.value = RecordingSessionState.Idle + mutableEvents.tryEmit(RecordingEvent.RecordingStartRejected(sessionId, error)) + return AppResult.failure(error) + } + + private fun validate(plan: RecordingPlan): AppResult { + val message = when { + plan.sources.isEmpty() -> "Recording plan has no sources" + + plan.durationMillis < 0L -> "Recording duration is negative" + + plan.sources.map(RecordingSourceSpec::type).distinct().size != plan.sources.size -> + "Recording plan repeats a source type" + + plan.sources.map(RecordingSourceSpec::type).any { it !in sourceByType } -> + "Recording source adapter is missing" + + else -> null + } + return message?.let(::validationFailure) ?: AppResult.success(Unit) + } + + private fun validationFailure(message: String): AppResult = AppResult.failure( + AppError(AppErrorCode.VALIDATION, "Validate recording plan", message), + ) + + private fun conflict(operation: String, sessionId: RecordingSessionId): AppResult = AppResult.failure( + AppError( + code = AppErrorCode.CONFLICT, + operation = operation, + diagnosticMessage = "$operation conflicts with the active session", + context = mapOf("sessionId" to sessionId.value), + ), + ) + + private fun scheduleDurationStop(plan: RecordingPlan) { + if (plan.durationMillis <= 0L) return + durationJob = scope.launch { + delay.pause(plan.durationMillis) + stop(plan.sessionId, RecordingStopReason.DURATION_EXPIRED) + } + } + + private fun RecordingSessionState.sessionId(): RecordingSessionId? = when (this) { + RecordingSessionState.Idle -> null + is RecordingSessionState.Preparing -> plan.sessionId + is RecordingSessionState.Prepared -> plan.sessionId + is RecordingSessionState.Running -> plan.sessionId + is RecordingSessionState.Stopping -> sessionId + } + + private companion object { + const val EVENT_BUFFER_SIZE = 16 + } +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt new file mode 100644 index 0000000..bf5e456 --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingModel.kt @@ -0,0 +1,92 @@ +package com.motionapps.sensorbox.recording + +import java.util.UUID + +@JvmInline +value class RecordingSessionId(val value: String) { + init { + require(value.isNotBlank()) + } + + companion object { + fun create(): RecordingSessionId = RecordingSessionId(UUID.randomUUID().toString()) + } +} + +enum class RecordingSourceType { + SESSION, + SENSOR, + GPS, + ACTIVITY_RECOGNITION, + SIGNIFICANT_MOTION, +} + +sealed interface RecordingSourceSpec { + val type: RecordingSourceType + + data object Session : RecordingSourceSpec { + override val type = RecordingSourceType.SESSION + } + + data class Sensors(val sensorTypes: Set, val samplingPeriod: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.SENSOR + } + + data class Gps(val intervalSeconds: Int, val minimumDistanceMeters: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.GPS + } + + data class ActivityRecognition(val periodSeconds: Int) : RecordingSourceSpec { + override val type = RecordingSourceType.ACTIVITY_RECOGNITION + } + + data object SignificantMotion : RecordingSourceSpec { + override val type = RecordingSourceType.SIGNIFICANT_MOTION + } +} + +data class RecordingPlan( + val sessionId: RecordingSessionId, + val sources: List, + val startAtEpochMillis: Long, + val durationMillis: Long = 0L, +) + +sealed interface RecordingSessionState { + data object Idle : RecordingSessionState + + data class Preparing(val plan: RecordingPlan) : RecordingSessionState + + data class Prepared(val plan: RecordingPlan) : RecordingSessionState + + data class Running(val plan: RecordingPlan, val startedAtEpochMillis: Long) : RecordingSessionState + + data class Stopping(val sessionId: RecordingSessionId, val reason: RecordingStopReason) : RecordingSessionState +} + +enum class RecordingStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + PLATFORM_DESTROYED, + PAIRED_ABORT, +} + +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.motionapps.sensorbox.core.error.AppError, + ) : RecordingEvent + + data class RecordingStopped( + override val sessionId: RecordingSessionId, + val reason: RecordingStopReason, + val result: com.motionapps.sensorbox.core.error.AppResult, + ) : RecordingEvent +} diff --git a/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt new file mode 100644 index 0000000..908737f --- /dev/null +++ b/recording-core/src/main/kotlin/com/motionapps/sensorbox/recording/RecordingSource.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppResult + +interface RecordingSource { + val type: RecordingSourceType + + suspend fun prepare(spec: RecordingSourceSpec): AppResult + + suspend fun start(): AppResult + + suspend fun stop(): AppResult +} + +fun interface RecordingClock { + fun epochMillis(): Long +} + +fun interface RecordingDelay { + suspend fun pause(durationMillis: Long) +} diff --git a/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt b/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt new file mode 100644 index 0000000..d64d37d --- /dev/null +++ b/recording-core/src/test/kotlin/com/motionapps/sensorbox/recording/RecordingEngineTest.kt @@ -0,0 +1,188 @@ +package com.motionapps.sensorbox.recording + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingEngineTest { + @Test + fun `Given unordered sources When committed Then sources start in stable order`() = runTest { + val calls = mutableListOf() + val gps = FakeSource(RecordingSourceType.GPS, calls) + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Gps(10, 20), + RecordingSourceSpec.Sensors(setOf(1), 0), + ) + + assertTrue(engine.prepare(plan).isSuccess) + assertTrue(engine.commit(plan.sessionId).isSuccess) + + assertEquals( + listOf("prepare:SENSOR", "prepare:GPS", "start:SENSOR", "start:GPS"), + calls, + ) + assertTrue(engine.state.value is RecordingSessionState.Running) + } + + @Test + fun `Given a partial start failure When committed Then started sources stop in reverse order`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls, startFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + engine.prepare(plan) + val result = engine.commit(plan.sessionId) + + assertTrue(result.isFailure) + assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) + assertEquals(RecordingSessionState.Idle, engine.state.value) + } + + @Test + fun `Given a prepare failure When prepared Then failing and prepared sources clean up in reverse order`() = + runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val gps = FakeSource(RecordingSourceType.GPS, calls, prepareFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + + val result = engine.prepare(plan) + + assertTrue(result.isFailure) + assertEquals(listOf("stop:GPS", "stop:SENSOR"), calls.takeLast(2)) + assertEquals(RecordingSessionState.Idle, engine.state.value) + } + + @Test + fun `Given stop failures When stopped Then every source is attempted`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls, stopFails = true) + val gps = FakeSource(RecordingSourceType.GPS, calls, stopFails = true) + val engine = engine(listOf(gps, sensors)) + val plan = plan( + RecordingSourceSpec.Sensors(setOf(1), 0), + RecordingSourceSpec.Gps(10, 20), + ) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val result = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + + assertTrue(result.isFailure) + assertTrue(calls.contains("stop:GPS")) + assertTrue(calls.contains("stop:SENSOR")) + } + + @Test + fun `Given a completed session When stop repeats Then sources are not stopped twice`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(sensors)) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val first = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + val second = engine.stop(plan.sessionId, RecordingStopReason.USER_REQUEST) + + assertEquals(first, second) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + @Test + fun `Given a duration When committed Then recording stops after duration`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = RecordingEngine( + sources = listOf(sensors), + scope = backgroundScope, + clock = RecordingClock { testScheduler.currentTime }, + delay = RecordingDelay { kotlinx.coroutines.delay(it) }, + ) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0), durationMillis = 500L) + + engine.prepare(plan) + engine.commit(plan.sessionId) + advanceTimeBy(500L) + runCurrent() + + assertEquals(RecordingSessionState.Idle, engine.state.value) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + @Test + fun `Given a running session When low battery stops it Then terminal state returns to idle`() = runTest { + val calls = mutableListOf() + val sensors = FakeSource(RecordingSourceType.SENSOR, calls) + val engine = engine(listOf(sensors)) + val plan = plan(RecordingSourceSpec.Sensors(setOf(1), 0)) + engine.prepare(plan) + engine.commit(plan.sessionId) + + val result = engine.stop(plan.sessionId, RecordingStopReason.LOW_BATTERY) + + assertTrue(result.isSuccess) + assertEquals(RecordingSessionState.Idle, engine.state.value) + assertEquals(1, calls.count { it == "stop:SENSOR" }) + } + + private fun engine(sources: List) = RecordingEngine( + sources = sources, + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + clock = RecordingClock { 0L }, + delay = RecordingDelay { }, + ) + + private fun plan(vararg sources: RecordingSourceSpec, durationMillis: Long = 0L) = RecordingPlan( + sessionId = RecordingSessionId.create(), + sources = sources.toList(), + startAtEpochMillis = 0L, + durationMillis = durationMillis, + ) +} + +private class FakeSource( + override val type: RecordingSourceType, + private val calls: MutableList, + private val prepareFails: Boolean = false, + private val startFails: Boolean = false, + private val stopFails: Boolean = false, +) : RecordingSource { + override suspend fun prepare(spec: RecordingSourceSpec): AppResult { + calls += "prepare:$type" + return if (prepareFails) failure("Prepare $type") else AppResult.success(Unit) + } + + override suspend fun start(): AppResult { + calls += "start:$type" + return if (startFails) failure("Start $type") else AppResult.success(Unit) + } + + override suspend fun stop(): AppResult { + calls += "stop:$type" + return if (stopFails) failure("Stop $type") else AppResult.success(Unit) + } + + private fun failure(operation: String): AppResult = AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, operation), + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ea7fb18..756485c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,8 @@ rootProject.name = "SensorBox" include( ":app", ":core", + ":core-common", + ":recording-core", ":sensorservices", ":wear", ":wearoslib",