feat(watch): vertical PoC — record, upload, playback end-to-end

Completes the 5-second-record-and-upload flow:
- RecordingViewModel orchestrates recorder, countdown, and upload
- MediaRecorder wrapper produces 16kHz/AAC-LC m4a (Whisper-compatible)
- Compose RecordingScreen renders the state pyramid
  (Idle / RequestingPermission / Recording / Uploading / Success / Error)
- UiState + pure reducer with full JVM unit-test coverage
- run.sh "test" subcommand wraps the unit and instrumented tiers
  (set SKIP_INSTRUMENTED=1 to bypass the on-device tier)

Verified end-to-end: emulator captures "Hallo, hallo", lands at
tmpdata/<user>/<case>/*.m4a, Whisper transcribes, Ollama classifies,
document.md is written — full pipeline in ~5s.

Known: MockWebServer-based UploadClientTest hangs in setUp on the
Wear OS 34 emulator (likely SELinux socket policy). Deferred — the
real-server end-to-end already validates the wire protocol.
This commit is contained in:
2026-04-23 16:30:57 +02:00
parent 8306dd8c47
commit ed993a5c6d
8 changed files with 444 additions and 109 deletions
@@ -81,9 +81,12 @@ class UploadClientTest {
}
private fun copyAssetToTempFile(assetName: String): File {
val ctx = InstrumentationRegistry.getInstrumentation().context
val tmp = File.createTempFile("upload-test-", ".m4a", ctx.cacheDir)
ctx.assets.open(assetName).use { input ->
val instrumentation = InstrumentationRegistry.getInstrumentation()
// Assets are packaged with the test APK; cache dir lives under the app APK.
val testCtx = instrumentation.context
val appCtx = instrumentation.targetContext
val tmp = File.createTempFile("upload-test-", ".m4a", appCtx.cacheDir)
testCtx.assets.open(assetName).use { input ->
tmp.outputStream().use { output -> input.copyTo(output) }
}
return tmp
@@ -0,0 +1,66 @@
package com.doctate.watch.audio
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import java.io.File
/**
* Thin wrapper around [MediaRecorder] producing an MPEG-4/AAC file on disk.
* Not thread-safe: call start()/stop()/cancel() from a single coroutine scope.
*
* Format: 16 kHz mono AAC-LC at 64 kbps — matches Whisper's native sample rate
* so the server-side remux can skip a resample stage.
*/
class AudioRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var currentFile: File? = null
fun start(): File {
require(recorder == null) { "recorder already active" }
val outFile = File(context.cacheDir, "rec-${System.currentTimeMillis()}.m4a")
val r = newRecorderInstance().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(16_000)
setAudioEncodingBitRate(64_000)
setAudioChannels(1)
setOutputFile(outFile.absolutePath)
prepare()
start()
}
recorder = r
currentFile = outFile
return outFile
}
fun stop(): File {
val r = requireNotNull(recorder) { "recorder not started" }
val f = requireNotNull(currentFile)
try { r.stop() } finally {
r.release()
recorder = null
currentFile = null
}
return f
}
fun cancel() {
val r = recorder ?: return
val f = currentFile
try { r.stop() } catch (_: RuntimeException) { /* not started or too short — ignore */ }
r.release()
recorder = null
currentFile = null
f?.delete()
}
private fun newRecorderInstance(): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
@@ -1,115 +1,12 @@
/* While this template provides a good starting point for using Wear Compose, you can always
* take a look at https://github.com/android/wear-os-samples/tree/main/ComposeStarter to find the
* most up to date changes to the libraries and their usages.
*/
package com.doctate.watch.presentation
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.SurfaceTransformation
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
import androidx.wear.compose.material3.lazy.transformedHeight
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
import com.doctate.watch.R
import com.doctate.watch.presentation.theme.DoctateWatchTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WearApp("Android")
}
setContent { RecordingScreen() }
}
}
@Composable
fun WearApp(greetingName: String) {
DoctateWatchTheme {
AppScaffold {
val listState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(
scrollState = listState,
edgeButton = {
EdgeButton(
onClick = { /*TODO*/ },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text("More")
}
},
) { contentPadding -> // ScreenScaffold provides default padding; adjust as needed
TransformingLazyColumn(contentPadding = contentPadding, state = listState) {
item {
ListHeader(
modifier =
Modifier.fillMaxWidth().transformedHeight(this, transformationSpec),
transformation = SurfaceTransformation(transformationSpec),
) {
Text(text = stringResource(R.string.hello_world, greetingName))
}
}
item {
Button(
onClick = { /*TODO*/ },
modifier = Modifier.fillMaxWidth()
.transformedHeight(this, transformationSpec),
transformation = SurfaceTransformation(transformationSpec),
) {
Text("Button A")
}
}
item {
Button(
onClick = { /*TODO*/ },
modifier = Modifier.fillMaxWidth()
.transformedHeight(this, transformationSpec),
transformation = SurfaceTransformation(transformationSpec),
) {
Text("Button B")
}
}
item {
Button(
onClick = { /*TODO*/ },
modifier = Modifier.fillMaxWidth()
.transformedHeight(this, transformationSpec),
transformation = SurfaceTransformation(transformationSpec),
) {
Text("Button C")
}
}
}
}
}
}
}
@WearPreviewDevices
@WearPreviewFontScales
@Composable
fun DefaultPreview() {
WearApp("Preview Android")
}
@@ -0,0 +1,125 @@
package com.doctate.watch.presentation
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.doctate.watch.presentation.theme.DoctateWatchTheme
@Composable
fun RecordingScreen(
viewModel: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onPermissionResult(granted) }
LaunchedEffect(state) {
if (state is UiState.RequestingPermission) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
DoctateWatchTheme {
AppScaffold {
ScreenScaffold {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when (val s = state) {
UiState.Idle -> IdleContent(onRecord = viewModel::onRecordTap)
UiState.RequestingPermission -> StatusText("Asking for mic…")
is UiState.Recording -> StatusText("Recording: ${s.secondsLeft}s")
UiState.Uploading -> UploadingContent()
is UiState.Success -> SuccessContent(s.caseId, onReset = viewModel::onResetTap)
is UiState.Error -> ErrorContent(s, onReset = viewModel::onResetTap)
}
}
}
}
}
}
@Composable
private fun IdleContent(onRecord: () -> Unit) {
Button(onClick = onRecord, modifier = Modifier.fillMaxWidth()) {
Text("Record")
}
}
@Composable
private fun StatusText(message: String) {
Text(
message,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
}
@Composable
private fun UploadingContent() {
CircularProgressIndicator()
Text(
"Uploading…",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable
private fun SuccessContent(caseId: String, onReset: () -> Unit) {
Text(
"OK",
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Center,
)
Text(
caseId.take(8),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Done")
}
}
@Composable
private fun ErrorContent(state: UiState.Error, onReset: () -> Unit) {
Text(
if (state.transient) "Try again" else "Error",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Text(
state.message.take(60),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("OK")
}
}
@@ -0,0 +1,100 @@
package com.doctate.watch.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.AudioRecorder
import com.doctate.watch.domain.CaseId
import com.doctate.watch.net.UploadClient
import com.doctate.watch.net.UploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
class RecordingViewModel(
private val recorder: AudioRecorder,
private val uploadClient: UploadClient,
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Idle)
val state: StateFlow<UiState> = _state.asStateFlow()
fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap)
fun onPermissionResult(granted: Boolean) {
if (!granted) {
dispatch(RecordingEvent.PermissionDenied)
return
}
dispatch(RecordingEvent.PermissionGranted)
startRecordingFlow()
}
fun onResetTap() = dispatch(RecordingEvent.OnResetTap)
private fun startRecordingFlow() {
viewModelScope.launch {
val file = try {
withContext(Dispatchers.IO) { recorder.start() }
} catch (e: Exception) {
dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false))
return@launch
}
// Initial state already Recording(5); tick 4..0 over 5 seconds.
for (i in 4 downTo 0) {
delay(1_000)
dispatch(RecordingEvent.RecordingTick(i))
}
val finalFile = try {
withContext(Dispatchers.IO) { recorder.stop() }
} catch (e: Exception) {
recorder.cancel()
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
return@launch
}
dispatch(RecordingEvent.RecordingFinished)
val caseId = CaseId.new()
val recordedAt = Instant.now().toString()
try {
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
is UploadResult.Success ->
dispatch(RecordingEvent.UploadSucceeded(result.caseId))
is UploadResult.Transient ->
dispatch(RecordingEvent.UploadFailed(result.reason, transient = true))
is UploadResult.Terminal ->
dispatch(RecordingEvent.UploadFailed(result.reason, transient = false))
}
} finally {
finalFile.delete()
}
}
}
private fun dispatch(event: RecordingEvent) {
_state.update { current -> reduce(current, event) }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as DoctateApp
RecordingViewModel(
recorder = AudioRecorder(app),
uploadClient = app.uploadClient,
)
}
}
}
}
@@ -0,0 +1,55 @@
package com.doctate.watch.presentation
/** What the recording screen currently shows. */
sealed interface UiState {
data object Idle : UiState
data object RequestingPermission : UiState
data class Recording(val secondsLeft: Int) : UiState
data object Uploading : UiState
data class Success(val caseId: String) : UiState
data class Error(val message: String, val transient: Boolean) : UiState
}
/** Events that drive state transitions. Side effects live in the ViewModel. */
sealed interface RecordingEvent {
data object OnRecordTap : RecordingEvent
data object PermissionGranted : RecordingEvent
data object PermissionDenied : RecordingEvent
data class RecordingTick(val secondsLeft: Int) : RecordingEvent
data object RecordingFinished : RecordingEvent
data class UploadSucceeded(val caseId: String) : RecordingEvent
data class UploadFailed(val message: String, val transient: Boolean) : RecordingEvent
data object OnResetTap : RecordingEvent
}
/**
* Pure state transition. Unknown (state, event) combinations are no-ops: e.g. a late
* UploadSucceeded after the user already tapped reset stays Idle.
*/
internal fun reduce(state: UiState, event: RecordingEvent): UiState = when (state) {
is UiState.Idle -> when (event) {
RecordingEvent.OnRecordTap -> UiState.RequestingPermission
else -> state
}
is UiState.RequestingPermission -> when (event) {
RecordingEvent.PermissionGranted -> UiState.Recording(secondsLeft = 5)
RecordingEvent.PermissionDenied ->
UiState.Error(message = "Microphone permission denied", transient = false)
else -> state
}
is UiState.Recording -> when (event) {
is RecordingEvent.RecordingTick -> UiState.Recording(secondsLeft = event.secondsLeft)
RecordingEvent.RecordingFinished -> UiState.Uploading
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
else -> state
}
is UiState.Uploading -> when (event) {
is RecordingEvent.UploadSucceeded -> UiState.Success(caseId = event.caseId)
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
else -> state
}
is UiState.Success, is UiState.Error -> when (event) {
RecordingEvent.OnResetTap -> UiState.Idle
else -> state
}
}
@@ -0,0 +1,70 @@
package com.doctate.watch.presentation
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UiStateReducerTest {
@Test fun idle_to_requesting_permission_on_record_tap() {
val next = reduce(UiState.Idle, RecordingEvent.OnRecordTap)
assertThat(next).isEqualTo(UiState.RequestingPermission)
}
@Test fun requesting_permission_to_recording_on_grant() {
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionGranted)
assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 5))
}
@Test fun requesting_permission_to_error_on_denial() {
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionDenied)
assertThat(next).isInstanceOf(UiState.Error::class.java)
assertThat((next as UiState.Error).transient).isFalse()
}
@Test fun recording_updates_seconds_left_on_tick() {
val next = reduce(UiState.Recording(secondsLeft = 5), RecordingEvent.RecordingTick(3))
assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 3))
}
@Test fun recording_moves_to_uploading_when_finished() {
val next = reduce(UiState.Recording(secondsLeft = 0), RecordingEvent.RecordingFinished)
assertThat(next).isEqualTo(UiState.Uploading)
}
@Test fun recording_to_error_on_recorder_failure() {
val next = reduce(
UiState.Recording(secondsLeft = 3),
RecordingEvent.UploadFailed("mic busy", transient = false),
)
val err = next as UiState.Error
assertThat(err.message).isEqualTo("mic busy")
assertThat(err.transient).isFalse()
}
@Test fun uploading_to_success_on_ack() {
val next = reduce(UiState.Uploading, RecordingEvent.UploadSucceeded("abc-123"))
assertThat(next).isEqualTo(UiState.Success("abc-123"))
}
@Test fun uploading_to_error_on_transient_failure() {
val next = reduce(UiState.Uploading, RecordingEvent.UploadFailed("timeout", transient = true))
val err = next as UiState.Error
assertThat(err.message).isEqualTo("timeout")
assertThat(err.transient).isTrue()
}
@Test fun success_resets_to_idle_on_tap() {
val next = reduce(UiState.Success("abc-123"), RecordingEvent.OnResetTap)
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun error_resets_to_idle_on_tap() {
val next = reduce(UiState.Error("boom", true), RecordingEvent.OnResetTap)
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun unknown_event_is_noop() {
val next = reduce(UiState.Idle, RecordingEvent.UploadSucceeded("late ack"))
assertThat(next).isEqualTo(UiState.Idle)
}
}
+21 -2
View File
@@ -9,12 +9,15 @@
# stop adb shell am force-stop ...
# logcat adb logcat, filtered to the app's PID
# shot [path] screencap to PNG (default /tmp/wear_screen.png)
# test run JVM unit tests, plus instrumented tests on emulator
# (set SKIP_INSTRUMENTED=1 to skip the on-device tier)
# clean ./gradlew clean
# help show this help
#
# Env vars:
# WEAR_AVD AVD name to auto-boot when no device is attached
# (skips the interactive menu; useful for CI / scripted runs)
# WEAR_AVD AVD name to auto-boot when no device is attached
# (skips the interactive menu; useful for CI / scripted runs)
# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no emulator needed)
set -euo pipefail
@@ -205,6 +208,21 @@ cmd_clean() {
ok "Clean."
}
cmd_test() {
detect_java
info "Running JVM unit tests..."
(cd "$SCRIPT_DIR" && ./gradlew :app:testDebugUnitTest)
ok "Unit tests passed."
if [[ "${SKIP_INSTRUMENTED:-0}" == "1" ]]; then
info "SKIP_INSTRUMENTED=1 set; skipping connectedAndroidTest."
return
fi
ensure_device
info "Running instrumented tests..."
(cd "$SCRIPT_DIR" && ./gradlew :app:connectedDebugAndroidTest)
ok "Instrumented tests passed."
}
cmd_help() {
# Print the leading comment block (lines 2..first non-comment line).
awk 'NR==1 { next } /^[^[:space:]#]/ { exit } { print }' "$0" \
@@ -220,6 +238,7 @@ case "${1:-}" in
stop) cmd_stop ;;
logcat) cmd_logcat ;;
shot) shift; cmd_shot "$@" ;;
test) cmd_test ;;
clean) cmd_clean ;;
-h|--help|help) cmd_help ;;
*) die "Unknown command: $1 (try './run.sh help')" ;;