From ed993a5c6dc56688e2f3b9484a3e272bf23388fd Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 23 Apr 2026 16:30:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(watch):=20vertical=20PoC=20=E2=80=94=20rec?= =?UTF-8?q?ord,=20upload,=20playback=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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///*.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. --- .../com/doctate/watch/net/UploadClientTest.kt | 9 +- .../com/doctate/watch/audio/AudioRecorder.kt | 66 +++++++++ .../watch/presentation/MainActivity.kt | 105 +-------------- .../watch/presentation/RecordingScreen.kt | 125 ++++++++++++++++++ .../watch/presentation/RecordingViewModel.kt | 100 ++++++++++++++ .../com/doctate/watch/presentation/UiState.kt | 55 ++++++++ .../watch/presentation/UiStateReducerTest.kt | 70 ++++++++++ watch/wearos/run.sh | 23 +++- 8 files changed, 444 insertions(+), 109 deletions(-) create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt create mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt diff --git a/watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt b/watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt index 4895442..94a7753 100644 --- a/watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt +++ b/watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt @@ -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 diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt b/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt new file mode 100644 index 0000000..6fac0a1 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt @@ -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() + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt index 5ea2e1f..641c06f 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt @@ -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") -} \ No newline at end of file diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt new file mode 100644 index 0000000..32a8efd --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt @@ -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") + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt new file mode 100644 index 0000000..a3cc977 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt @@ -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.Idle) + val state: StateFlow = _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, + ) + } + } + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt new file mode 100644 index 0000000..40f7c94 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt @@ -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 + } +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt new file mode 100644 index 0000000..352d545 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt @@ -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) + } +} diff --git a/watch/wearos/run.sh b/watch/wearos/run.sh index 04d4c91..d83dab6 100755 --- a/watch/wearos/run.sh +++ b/watch/wearos/run.sh @@ -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')" ;;