Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ ANDROID_HOME="$HOME/Library/Android/sdk" tools/emulator/run_wear_sync_test.sh

The runner detects one phone and one watch automatically; `PHONE_SERIAL` and `WEAR_SERIAL` remain available when several devices are connected. It builds and installs once, refreshes the ADB bridge after installation, and launches each scenario on both devices. When an emulator transport exposes its paired node but does not propagate static capabilities, the instrumentation-only repository falls back to that connected node; file transfer still uses the production Channel client and receiver. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected recording archive.

The phone-driven paired recording test runs instrumentation only on the phone. It discovers a real Wear sensor, starts and stops a watch recording over the production message protocol, asks Wear OS to sync measurements, and verifies the transferred metadata and sensor samples on the phone:

```shell
PHONE_SERIAL=emulator-5554 WEAR_SERIAL=emulator-5556 \
tools/emulator/run_phone_paired_recording_test.sh
```

No Firebase project, Maps key, secrets file, or external storage permission is required.

## Dependencies
Expand Down
1 change: 1 addition & 0 deletions WearOsLib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testFixturesImplementation(project(":core-common"))
testFixturesImplementation(libs.coroutines.core)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.tomasrepcik.sensorbox.wearoslib.protocol

import com.tomasrepcik.sensorbox.core.error.AppError
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.core.error.AppResult
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap

fun interface RecordingCommandSender {
suspend fun send(command: WearCommand): AppResult<Unit>
}

fun interface RecordingResultReceiver {
fun receive(result: WearCommand.RecordingResult)
}

class RecordingCommandExchange(
private val sendCommand: SendWearCommandUseCase,
private val peerCapability: String,
private val peerPath: String,
private val peerName: String,
) : RecordingCommandSender,
RecordingResultReceiver {
private val results = ConcurrentHashMap<ResultKey, WearCommand.RecordingResult>()
private val updates = MutableSharedFlow<WearCommand.RecordingResult>(extraBufferCapacity = RESULT_BUFFER_SIZE)

@Suppress("ReturnCount")
override suspend fun send(command: WearCommand): AppResult<Unit> {
val operation = command.recordingOperation()
?: return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Exchange recording command"))
val sessionId = checkNotNull(command.recordingSessionId())
clear(sessionId, operation)

var lastSendError: AppError? = null
var commandWasSent = false
repeat(ATTEMPT_COUNT) { retryCount ->
when (val sent = sendCommand(peerCapability, peerPath, command)) {
is AppResult.Failure -> lastSendError = sent.error

is AppResult.Success -> {
commandWasSent = true
lastSendError = null
val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) {
await(sessionId, operation)
}
if (result != null) return result.toAppResult(retryCount)
}
}
}

if (!commandWasSent && lastSendError != null) return AppResult.failure(checkNotNull(lastSendError))
return timeout(sessionId, operation, commandWasSent)
}

override fun receive(result: WearCommand.RecordingResult) {
results[result.key()] = result
updates.tryEmit(result)
}

private fun clear(sessionId: String, operation: WearRecordingOperation) {
results.remove(ResultKey(sessionId, operation))
}

private suspend fun await(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult {
val key = ResultKey(sessionId, operation)
results[key]?.let { return it }
return updates.first { result -> result.key() == key }
}

private fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult<Unit> =
if (outcome == WearRecordingOutcome.SUCCEEDED) {
AppResult.success(Unit)
} else {
AppResult.failure(
AppError(
code = errorCode ?: AppErrorCode.UNKNOWN,
operation = errorOperation ?: "Handle $peerName $operation result",
diagnosticMessage = errorMessage ?: "$peerName $operation failed",
context = errorContext + mapOf(
"source" to peerName,
"sessionId" to sessionId,
"retryCount" to retryCount.toString(),
"failureCount" to failureCount.toString(),
"protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(),
),
),
)
}

private fun timeout(
sessionId: String,
operation: WearRecordingOperation,
commandWasSent: Boolean,
): AppResult<Unit> = AppResult.failure(
AppError(
code = AppErrorCode.TIMEOUT,
operation = "Await $peerName $operation result",
diagnosticMessage = "$peerName $operation result timed out",
context = mapOf(
"source" to peerName,
"sessionId" to sessionId,
"retryCount" to RETRY_COUNT.toString(),
"protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(),
"commandWasSent" to commandWasSent.toString(),
),
isRetryable = true,
),
)

private fun WearCommand.RecordingResult.key() = ResultKey(sessionId, operation)

private data class ResultKey(val sessionId: String, val operation: WearRecordingOperation)

private companion object {
const val RESULT_TIMEOUT_MILLIS = 5_000L
const val RETRY_COUNT = 2
const val ATTEMPT_COUNT = RETRY_COUNT + 1
const val RESULT_BUFFER_SIZE = 16
}
}

fun AppError.wasRecordingCommandSent(): Boolean =
code == AppErrorCode.TIMEOUT && context["commandWasSent"] == true.toString()

private fun WearCommand.recordingOperation(): WearRecordingOperation? = when (this) {
is WearCommand.StartRecording -> WearRecordingOperation.START
is WearCommand.StopRecording -> WearRecordingOperation.STOP
else -> null
}
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ android {
dependencies {
implementation(project(":core"))
implementation(project(":sensorservices"))
implementation(project(":recording-core"))
implementation(project(":wearoslib"))

implementation(libs.androidx.core.ktx)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.tomasrepcik.sensorbox.presentation.main

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.tomasrepcik.sensorbox.core.error.AppErrorCode
import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class AppErrorScreenTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun givenStorageFailureWhenRenderedThenLocalizedMessageCanBeDismissed() {
// Given
var dismissed = false

// When
composeRule.setContent {
SensorBoxTheme {
AppErrorScreen(AppErrorCode.STORAGE) { dismissed = true }
}
}

// Then
composeRule.onNodeWithText("Something went wrong").assertIsDisplayed()
composeRule.onNodeWithText(
"Local storage could not be read or updated. The error was saved in diagnostics.",
).assertIsDisplayed()
composeRule.onNodeWithText("Close").performClick()
assertTrue(dismissed)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ class MeasurementBrowserScreenTest {
)

composeRule.onNodeWithText("2 chart samples").assertIsDisplayed()
composeRule.onNodeWithText("Sensor details").assertIsDisplayed()
composeRule.onNodeWithText("Bosch accelerometer").assertIsDisplayed()
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Zoom in"))
composeRule.onNodeWithText("x, y, z").assertIsDisplayed()
composeRule.onNodeWithText("Zoom out").assertIsDisplayed()
composeRule.onNodeWithText("Show all").assertIsDisplayed()
Expand Down Expand Up @@ -115,7 +118,11 @@ class MeasurementBrowserScreenTest {
composeRule.setContent {
SensorBoxTheme {
MeasurementFileScreen(
state = MeasurementBrowserState(selectedFile = file, selectedFileContent = content),
state = MeasurementBrowserState(
selectedMeasurement = details,
selectedFile = file,
selectedFileContent = content,
),
onIntent = onIntent,
onBack = {},
)
Expand All @@ -131,6 +138,12 @@ class MeasurementBrowserScreenTest {
summary,
listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")),
listOf(sensorFile, gpsFile),
sensorMetadataByFile = mapOf(
sensorFile.id to listOf(
MeasurementMetadataEntry("sensor", "Bosch accelerometer"),
MeasurementMetadataEntry("writtenSamples", "2"),
),
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme
import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense
import org.junit.Rule
import org.junit.Test

Expand All @@ -16,7 +21,28 @@ class OpenSourceLicensesScreenTest {
fun givenGeneratedLicenseMetadataWhenLicenseIsOpenedThenItsTextIsShown() {
composeRule.setContent {
SensorBoxTheme {
OpenSourceLicensesScreen(onBack = {})
var state by remember {
mutableStateOf(
SettingsState(
openSourceLicenses = listOf(
OpenSourceLicense(
"Debug License Info",
"Licenses are only provided in build variants (e.g. release) where the Android " +
"Gradle Plugin generates an app dependency list.",
),
),
),
)
}
OpenSourceLicensesScreen(
state = state,
onIntent = { intent ->
if (intent is SettingsIntent.SelectOpenSourceLicense) {
state = state.copy(selectedLicenseName = intent.name)
}
},
onBack = {},
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class SensorDetailsScreenTest {
),
onBack = {},
onPreview = {},
onIntent = {},
)
}
}
Expand Down
Loading