diff --git a/README.md b/README.md index a03293b..9629b45 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,19 @@ The UI modules use unidirectional MVI: `Composable → Intent → ViewModel → use case → repository/service → State + Effect` -UI launchers execute one-shot effects, while decisions and state transitions remain in ViewModels, reducers, and focused use cases. Hilt provides production dependencies and interfaces keep platform boundaries replaceable in tests. +UI launchers execute one-shot effects, while decisions and state transitions remain in workflow-owned ViewModels, reducers, and focused use cases. The phone shell owns navigation only. Hilt provides production dependencies and interfaces keep platform boundaries replaceable in tests. Modules: -- `app`: phone Compose UI, MVI, permissions, native document storage, and received watch files. +- `app`: phone Compose UI, workflow-owned MVI, paired-recording policy, permissions, and received watch files. - `wear`: Wear Compose Material 3 UI, MVI, live charts, recording, and phone launch flow. -- `core`: DataStore preferences, storage contracts, reducers, and reusable test fixtures. -- `sensorservices`: foreground measurement service and linear sensor/GPS writers. -- `WearOsLib`: coroutine-based connectivity, versioned command protocol, and Channel file transport. +- `core-common`: platform-neutral `AppResult`, stable application errors, and diagnostics contracts. +- `recording-core`: pure Kotlin recording state machine, source roles, scheduling, and cleanup policy. +- `core`: Android DataStore preferences, document storage, local rotating diagnostics, and reusable test fixtures. +- `sensorservices`: Android recording adapters, foreground host, and linear sensor/GPS writers. It has no Wear dependency. +- `WearOsLib`: coroutine-based connectivity, strict protocol v2 command encoding, and Channel file transport. App policy stays in `app` and `wear`. + +Paired phone/watch recording is all-or-nothing: both sides prepare before either commits, commands are session-correlated and idempotent, timeouts use bounded retries, and rejection or timeout compensates both sides. ## Platform and toolchain diff --git a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt b/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt deleted file mode 100644 index 55c2e72..0000000 --- a/WearOsLib/src/main/java/com/motionapps/wearoslib/protocol/WearSensorCatalogStore.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.motionapps.wearoslib.protocol - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WearSensorCatalogStore @Inject constructor() { - private val mutableSensors = MutableStateFlow>(emptyList()) - val sensors: StateFlow> = mutableSensors.asStateFlow() - - fun update(sensors: List) { - mutableSensors.value = sensors.distinctBy(WearSensorInfo::type).sortedBy(WearSensorInfo::name) - } - - fun clear() { - mutableSensors.value = emptyList() - } -} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt deleted file mode 100644 index 67d5338..0000000 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainReducerTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.motionapps.sensorbox.presentation.main - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class MainReducerTest { - @Test - fun `Given an unselected sensor When toggled Then it becomes selected`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleSensor(sensorId = 1)) - - assertTrue(1 in actual.state.selectedSensorIds) - } - - @Test - fun `Given record state When storage is requested Then picker effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ChooseStorage) - - assertEquals(MainEffect.PickStorageDirectory, actual.effect) - } - - @Test - fun `Given GPS disabled When toggled Then GPS becomes enabled`() { - val givenState = MainStateFixtures.state(includesGps = false) - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleGps) - - assertTrue(actual.state.includesGps) - } - - @Test - fun `Given an unselected Wear sensor When toggled Then only Wear selection changes`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.ToggleWearSensor(sensorId = 21)) - - assertTrue(21 in actual.state.selectedWearSensorIds) - assertTrue(actual.state.selectedSensorIds.isEmpty()) - } - - @Test - fun `Given endless mode When timed mode is selected Then timing configuration is retained`() { - val givenState = MainStateFixtures.state().copy(durationSeconds = 60) - - val actual = MainReducer.reduce(givenState, MainIntent.SetMeasurementType("TIMED")) - - assertEquals("TIMED", actual.state.measurementType) - assertEquals(60, actual.state.durationSeconds) - } - - @Test - fun `Given selected sensors When setup is opened Then setup route is shown`() { - val givenState = MainStateFixtures.state(selectedSensorIds = setOf(1)) - - val actual = MainReducer.reduce(givenState, MainIntent.OpenMeasurementSetup) - - assertEquals(MainRoute.SETUP, actual.state.route) - } - - @Test - fun `Given a sensor When its information is opened Then details route retains its type`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenSensorDetails(sensorType = 1)) - - assertEquals(MainRoute.SENSOR_DETAILS, actual.state.route) - assertEquals(1, actual.state.detailsSensorType) - } - - @Test - fun `Given measurement setup When returning Then sensor selection is shown`() { - val givenState = MainStateFixtures.state(route = MainRoute.SETUP, selectedSensorIds = setOf(1)) - - val actual = MainReducer.reduce(givenState, MainIntent.ReturnToSensorSelection) - - assertEquals(MainRoute.RECORD, actual.state.route) - } - - @Test - fun `Given later introduction page When going back Then previous page is shown`() { - val givenState = MainStateFixtures.state().copy(onboardingPage = 3) - - val actual = MainReducer.reduce(givenState, MainIntent.RetreatOnboarding) - - assertEquals(2, actual.state.onboardingPage) - } - - @Test - fun `Given policy introduction When privacy is opened Then privacy effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenPrivacyPolicy) - - assertEquals(MainEffect.OpenPrivacyPolicy, actual.effect) - } - - @Test - fun `Given policy introduction When terms are opened Then terms effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.OpenTermsOfUse) - - assertEquals(MainEffect.OpenTermsOfUse, actual.effect) - } - - @Test - fun `Given battery introduction When exemption is requested Then settings effect is emitted`() { - val givenState = MainStateFixtures.state() - - val actual = MainReducer.reduce(givenState, MainIntent.RequestBatteryOptimizationExemption) - - assertEquals(MainEffect.RequestBatteryOptimizationExemption, actual.effect) - } -} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainStateFixtures.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainStateFixtures.kt deleted file mode 100644 index 18f5cad..0000000 --- a/app/src/test/java/com/motionapps/sensorbox/presentation/main/MainStateFixtures.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.motionapps.sensorbox.presentation.main - -import com.motionapps.sensorbox.domain.sensors.SensorDescriptor - -object MainStateFixtures { - fun state( - route: MainRoute = MainRoute.RECORD, - selectedSensorIds: Set = emptySet(), - includesGps: Boolean = false, - ) = MainState( - route = route, - sensors = listOf( - SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture", isHeartRate = false), - ), - selectedSensorIds = selectedSensorIds, - includesGps = includesGps, - ) -} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt deleted file mode 100644 index bed03b5..0000000 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/AppDiagnostics.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.motionapps.sensorbox.core.error - -import android.content.Context -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -object AppDiagnostics { - private val lock = Any() - private val pending = ArrayDeque() - - @Volatile - private var applicationContext: Context? = null - - @Volatile - private var uncaughtHandlerInstalled = false - - fun install(context: Context) { - synchronized(lock) { - applicationContext = context.applicationContext - installUncaughtExceptionHandler() - val queued = pending.toList() - pending.clear() - queued.forEach(::appendSafely) - } - } - - private fun installUncaughtExceptionHandler() { - if (uncaughtHandlerInstalled) return - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, error -> - AppError.from(AppError.Kind.UNKNOWN, "Uncaught exception on ${thread.name}", error) - previous?.uncaughtException(thread, error) - } - uncaughtHandlerInstalled = true - } - - fun record(error: AppError) { - val entry = error.toDiagnosticEntry() - synchronized(lock) { - if (applicationContext == null) { - if (pending.size == MAX_PENDING_ENTRIES) pending.removeFirst() - pending.addLast(entry) - } else { - appendSafely(entry) - } - } - } - - fun readText(): Result = appResult(AppError.Kind.STORAGE, "Read diagnostics") { - synchronized(lock) { - val file = diagnosticsFile() - if (file.exists()) file.readText() else NO_DIAGNOSTICS - } - } - - fun exportFile(): Result = appResult(AppError.Kind.STORAGE, "Export diagnostics") { - synchronized(lock) { - diagnosticsFile().also { file -> - file.parentFile?.mkdirs() - if (!file.exists()) file.writeText(NO_DIAGNOSTICS) - } - } - } - - fun clear(): Result = appResult(AppError.Kind.STORAGE, "Clear diagnostics") { - synchronized(lock) { - val file = diagnosticsFile() - check(!file.exists() || file.delete()) { "Unable to delete diagnostics" } - } - } - - private fun appendSafely(entry: String) { - try { - val file = diagnosticsFile() - file.parentFile?.mkdirs() - if (file.length() + entry.length > MAX_FILE_BYTES) rotate(file) - file.appendText(entry) - } catch (_: Throwable) { - // Diagnostics must never become a second failure source. - } - } - - private fun rotate(file: File) { - val previous = File(file.parentFile, PREVIOUS_FILE_NAME) - if (previous.exists()) previous.delete() - if (file.exists()) file.renameTo(previous) - } - - private fun diagnosticsFile(): File { - val context = checkNotNull(applicationContext) { "Diagnostics are not initialized" } - return File(File(context.filesDir, DIRECTORY_NAME), FILE_NAME) - } - - private fun AppError.toDiagnosticEntry(): String = buildString { - val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date()) - append(timestamp).append(" | ").append(kind).append(" | ").append(operation).appendLine() - append(stackTraceToString().take(MAX_ENTRY_CHARS)).appendLine() - appendLine(ENTRY_SEPARATOR) - } - - private const val DIRECTORY_NAME = "diagnostics" - private const val FILE_NAME = "sensorbox-diagnostics.txt" - private const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt" - private const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - private const val ENTRY_SEPARATOR = "---" - private const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n" - private const val MAX_PENDING_ENTRIES = 20 - private const val MAX_ENTRY_CHARS = 32_000 - private const val MAX_FILE_BYTES = 1_000_000L -} diff --git a/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt b/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt deleted file mode 100644 index 5fd0da9..0000000 --- a/core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.motionapps.sensorbox.core.error - -import kotlinx.coroutines.CancellationException - -class AppError(val kind: Kind, val operation: String, cause: Throwable? = null) : - Exception(message(operation, cause), cause) { - init { - AppDiagnostics.record(this) - } - - enum class Kind { - CONNECTIVITY, - EXTERNAL_ACTION, - MEASUREMENT, - PERMISSION, - PREFERENCES, - STORAGE, - UNKNOWN, - } - - companion object { - fun from(kind: Kind, operation: String, cause: Throwable): AppError = - cause as? AppError ?: AppError(kind, operation, cause) - - private fun message(operation: String, cause: Throwable?): String = - cause?.message?.takeIf(String::isNotBlank)?.let { "$operation: $it" } ?: "$operation failed" - } -} - -@Suppress("TooGenericExceptionCaught") -inline fun appResult(kind: AppError.Kind, operation: String, block: () -> T): Result = try { - Result.success(block()) -} catch (error: CancellationException) { - throw error -} catch (error: Throwable) { - Result.failure(AppError.from(kind, operation, error)) -} - -@Suppress("TooGenericExceptionCaught") -suspend inline fun suspendAppResult( - kind: AppError.Kind, - operation: String, - crossinline block: suspend () -> T, -): Result = try { - Result.success(block()) -} catch (error: CancellationException) { - throw error -} catch (error: Throwable) { - Result.failure(AppError.from(kind, operation, error)) -} - -fun Result.withAppError(kind: AppError.Kind, operation: String): Result = fold( - onSuccess = Result.Companion::success, - onFailure = { Result.failure(AppError.from(kind, operation, it)) }, -) - -inline fun Result.flatMap(transform: (T) -> Result): Result = fold( - onSuccess = transform, - onFailure = Result.Companion::failure, -) - -suspend inline fun Result.suspendFlatMap(crossinline transform: suspend (T) -> Result): Result = fold( - onSuccess = { transform(it) }, - onFailure = Result.Companion::failure, -) - -fun Iterable>.combineAppResults(kind: AppError.Kind, operation: String): Result { - val failures = mapNotNull(Result<*>::exceptionOrNull) - if (failures.isEmpty()) return Result.success(Unit) - val first = failures.first() - failures.drop(1).forEach(first::addSuppressed) - return Result.failure(AppError.from(kind, operation, first)) -} diff --git a/docs/adr/0001-use-explicit-application-outcomes.md b/docs/adr/0001-use-explicit-application-outcomes.md new file mode 100644 index 0000000..5244340 --- /dev/null +++ b/docs/adr/0001-use-explicit-application-outcomes.md @@ -0,0 +1,5 @@ +# Use explicit application outcomes + +Expected failures crossing repository, storage, protocol, recording, use-case, or presentation interfaces use a shared `AppResult` and data-only `AppError`. Kotlin `Result` and custom exceptions do not cross those interfaces because callers need stable error codes, exhaustive handling, and diagnostics without constructor side effects. + +Infrastructure adapters translate expected exceptions and preserve safe causes. Application entry points record terminal failures through an injected local diagnostic logger. User-facing messages remain presentation decisions. diff --git a/docs/adr/0002-separate-recording-engine-from-android-adapters.md b/docs/adr/0002-separate-recording-engine-from-android-adapters.md new file mode 100644 index 0000000..e967f9e --- /dev/null +++ b/docs/adr/0002-separate-recording-engine-from-android-adapters.md @@ -0,0 +1,5 @@ +# Separate the recording engine from Android adapters + +Recording state, scheduling policy, source coordination, cleanup, and terminal events live in a pure Kotlin `recording-core` module. Android sensor capture, foreground hosting, notifications, wake locks, battery callbacks, GPS, and storage streams remain adapters in `sensorservices`. + +This split keeps the recording state machine testable without Android and prevents Wear transport policy from entering the local recording module. diff --git a/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md b/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md new file mode 100644 index 0000000..616fb88 --- /dev/null +++ b/docs/adr/0003-coordinate-paired-recordings-with-protocol-v2.md @@ -0,0 +1,5 @@ +# Coordinate paired recordings with protocol version 2 + +Paired phone and watch recording uses a breaking, session-identified prepare, commit, abort, and stop protocol. Both sides must prepare before either side commits. Commands are idempotent, bounded by timeouts, and retried with the same session ID. + +The phone owns the paired workflow. `WearOsLib` owns typed protocol encoding and transport, while phone and watch applications own command behavior. Unsupported protocol versions fail explicitly. diff --git a/docs/adr/0004-own-presentation-state-by-user-workflow.md b/docs/adr/0004-own-presentation-state-by-user-workflow.md new file mode 100644 index 0000000..1466b17 --- /dev/null +++ b/docs/adr/0004-own-presentation-state-by-user-workflow.md @@ -0,0 +1,5 @@ +# Own presentation state by user workflow + +Phone presentation state is owned by onboarding, recording, and settings workflows rather than one application-wide MVI contract. The recording workflow spans selection, details, preview, setup, permissions, active recording, and annotations because those routes edit or observe one recording plan. + +The application shell owns navigation and a small read-only view of recording session facts. Routes remain stateless and receive focused state and actions from their owning feature. diff --git a/docs/architecture-refactor-plan.md b/docs/architecture-refactor-plan.md new file mode 100644 index 0000000..2d1fbff --- /dev/null +++ b/docs/architecture-refactor-plan.md @@ -0,0 +1,117 @@ +# SensorBox architecture refactor plan + +Status: implemented and verified on 2026-08-19. The phone emulator workflow passes. The Wear-to-phone emulator workflow is built but awaits a one-time Android Studio phone/watch pairing. + +This file is the implementation source of truth for the Android 17 hard-cut refactor. Update the status boxes as work lands. Do not preserve an old interface merely because a later step still uses it. Each completed step must leave the repository in a valid intermediate state. + +Related decisions: + +- [0001: Use explicit application outcomes](adr/0001-use-explicit-application-outcomes.md) +- [0002: Separate the recording engine from Android adapters](adr/0002-separate-recording-engine-from-android-adapters.md) +- [0003: Coordinate paired recordings with protocol version 2](adr/0003-coordinate-paired-recordings-with-protocol-v2.md) +- [0004: Own presentation state by user workflow](adr/0004-own-presentation-state-by-user-workflow.md) + +## Completion rules + +- [x] Every module interface uses `AppResult` for expected outcomes. Kotlin `Result` does not cross a module interface. +- [x] Every intermediate implementation state compiles and passes the checks relevant to its changes. +- [ ] The final state passes unit tests, Detekt, phone and watch lint, Android test compilation, and both emulator workflows. +- [x] Tests cross the same interfaces used by production callers. +- [x] Diagnostics remain local until the user copies or shares them. +- [x] `sensorservices` has no dependency on `wearoslib`. +- [x] Unsupported Wear protocol versions fail explicitly. No compatibility layer remains. + +## Phase 1: outcomes and diagnostics + +- [x] Replace exception-backed `AppError` with a data-only error. +- [x] Add stable error codes for connectivity, external actions, measurement, permission, preferences, storage, validation, timeout, conflict, and unknown failures. +- [x] Add `AppResult.Success` and `AppResult.Failure` plus composition helpers. +- [x] Translate expected exceptions at infrastructure adapters and rethrow coroutine cancellation. +- [x] Add `DiagnosticLogger` for writes and `DiagnosticsStore` for read, export, and clear operations. +- [x] Store structured, redacted diagnostics in app-private rotating files. +- [x] Keep the current and previous diagnostic file at no more than 1 MB each. +- [x] Expose view, copy, share-text, share-file, and clear actions from Settings. +- [x] Add tests for outcome composition, cancellation, redaction, rotation, and logger write failure. + +Diagnostic entries may contain timestamps, severity, stable codes, operations, app/build information, Android/device model information, process names, session IDs, lifecycle states, stop reasons, source types, protocol versions, retry counts, sanitized exception classes, stack traces, durations, source counts, and sampling periods. + +Diagnostic entries must not contain samples, coordinates, annotations, command payloads, full recording names, complete file names, SAF URIs, or persistent device identifiers. + +## Phase 2: pure recording engine + +- [x] Add a pure Kotlin `recording-core` module. +- [x] Define typed recording plans, session IDs, source types, stop reasons, states, commands, and terminal events. +- [x] Define the small source role used internally by the engine. +- [x] Prepare and start sources in stable order. +- [x] On start failure, stop all prepared sources in reverse order. +- [x] On stop, attempt every active source and combine failures. +- [x] Make repeated stop idempotent. +- [x] Emit typed stop events for duration expiry, low battery, source failure, platform destruction, and user requests. +- [x] Test partial start, reverse cleanup, repeated stop, timed stop, low-battery stop, and combined cleanup failure. + +The durable state sequence is `Idle`, `Preparing`, `Prepared`, `Running`, and `Stopping`. Terminal results are `RecordingStarted`, `RecordingStartRejected`, and `RecordingStopped` events. The engine returns to `Idle` after a terminal event. + +## Phase 3: Android recording adapters + +- [x] Keep Android sensors, GPS, activity recognition, significant motion, storage streams, alarms, wake locks, battery callbacks, notifications, and foreground hosting in `sensorservices`. +- [x] Replace `Bundle` source configuration with typed configuration. +- [x] Make `MeasurementService` translate intents into engine commands. +- [x] Publish engine state through an application-scoped read-only session repository. +- [x] Deliver typed session events to application-provided observers. +- [x] Remove Wear commands, codecs, connection collaborators, and paired flags from `sensorservices`. +- [x] Remove the `sensorservices -> wearoslib` Gradle dependency. + +## Phase 4: Wear protocol version 2 + +- [x] Add session identity to every paired recording command. +- [x] Add prepare, commit, abort, stop, and correlated acknowledgement messages with typed outcomes. +- [x] Make handlers idempotent and return the prior outcome for duplicate commands. +- [x] Reject a command for a conflicting active session with `CONFLICT`. +- [x] Reject unsupported protocol versions. +- [x] Keep command models, encoding, decoding, connection discovery, and byte transport in `WearOsLib`. +- [x] Add round-trip, unsupported-version, duplicate-command, and malformed-payload tests. + +## Phase 5: paired recording workflow + +- [x] Put phone and watch coordination in one phone-side workflow. +- [x] Prepare both sides before committing either side. +- [x] Give prepare 10 seconds and commit acknowledgement 5 seconds. +- [x] Retry each message twice with the same session ID. +- [x] Abort both sides after rejection or final timeout. +- [x] Never fall back to phone-only recording when the plan contains watch sources. +- [x] Propagate every automatic local stop to the watch through the typed session observer. +- [x] Stop both sides even if one stop fails, then return a combined outcome. +- [x] Test remote rejection, prepare timeout, commit compensation, duplicate messages, and partial stop failure. + +## Phase 6: presentation ownership + +- [x] Make the application shell own navigation and only the session facts needed for navigation and screen-awake policy. +- [x] Give onboarding its own state, actions, effects, and ViewModel. +- [x] Give the full recording journey one navigation-graph-scoped ViewModel. +- [x] Give settings its own state, actions, effects, and ViewModel. +- [x] Keep child routes stateless and pass focused state and actions. +- [x] Move recording request construction, validation, permission resolution, and start effects into the recording feature. +- [x] Test ViewModel actions, state transitions, effects, and representative error codes. + +The recording journey includes sensor selection, sensor details, live preview, measurement setup, permission resolution, active recording, and annotations. + +## Phase 7: typed Wear handlers + +- [x] Keep listener services limited to callback validation, decoding, and dispatch. +- [x] Put phone command behavior in `PhoneWearCommandHandler` in `app`. +- [x] Put watch command behavior in `WearCommandHandler` in `wear`. +- [x] Keep application preferences, permissions, recording behavior, and navigation out of `WearOsLib`. +- [x] Test handlers with typed commands and local adapters, without Google Play Services callback objects. + +## Final verification + +- [x] `./gradlew testDebugUnitTest detekt` +- [x] `./gradlew :app:assembleDebug :wear:assembleDebug` +- [x] `./gradlew :app:lintDebug :wear:lintDebug` +- [x] `./gradlew :app:compileDebugAndroidTestKotlin :wear:compileDebugAndroidTestKotlin` +- [x] Run the phone sensor recording emulator workflow. +- [ ] Run the Wear-to-phone transfer emulator workflow. +- [x] Update README architecture and test instructions. +- [x] Confirm the final worktree contains no obsolete result, protocol, presentation, or service interfaces. + +Wear workflow note: `tools/emulator/run_wear_sync_test.sh` reached its pairing precondition and exited with the expected guidance because `SensorBox_Wear_API_37` reports no paired phone. APKs and both instrumentation suites compile. Complete the one-time Pair Wearable flow in Android Studio, then rerun the script. diff --git a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt b/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt deleted file mode 100644 index 2b1cfbd..0000000 --- a/sensorservices/src/main/java/com/motionapps/sensorservices/handlers/measurements/MeasurementInterface.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.motionapps.sensorservices.handlers.measurements - -import android.content.Context -import android.os.Bundle - -/** - * basic methods, which handlers have to call them at once with service lifecycle events - * - */ -interface MeasurementInterface { - - fun initMeasurement(context: Context, params: Bundle): Result - fun startMeasurement(context: Context): Result - fun pauseMeasurement(context: Context): Result - suspend fun saveMeasurement(context: Context): Result - suspend fun onDestroyMeasurement(context: Context): Result - - companion object { - // keys for the bundle in service to handlers - const val FOLDER_NAME = "FOLDER_NAME" - const val SENSOR_ID = "SENSOR_ID" - const val SENSOR_SPEED = "SENSOR_SPEED" - const val INTERNAL_STORAGE = "INTERNAL_STORAGE" - } -}