From 8306dd8c47d109cc51d2b65039f7f4daeec9b85e Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 23 Apr 2026 15:25:53 +0200 Subject: [PATCH] feat(watch): scaffold upload client and app service locator Builds the network foundation for the Wear OS vertical PoC: OkHttp-based UploadClient mirroring doctate-client-core's wire protocol (multipart /api/upload, X-API-Key, transient/terminal classification), BuildConfig-backed Settings sourced from local.properties, CaseId factory, DoctateApp service locator, MockWebServer-based UploadClientTest for wire-format assertions, plus the manifest/permissions/network-security- config plumbing for emulator-loopback cleartext. MainActivity is still the placeholder UI; audio recording, ViewModel, and recording screen come in follow-up commits. --- watch/wearos/app/build.gradle.kts | 35 +++++++ .../app/src/androidTest/assets/sample.m4a | Bin 0 -> 17581 bytes .../com/doctate/watch/net/UploadClientTest.kt | 91 ++++++++++++++++++ watch/wearos/app/src/main/AndroidManifest.xml | 5 + .../main/java/com/doctate/watch/DoctateApp.kt | 18 ++++ .../java/com/doctate/watch/domain/CaseId.kt | 13 +++ .../doctate/watch/net/HttpClientProvider.kt | 17 ++++ .../com/doctate/watch/net/UploadClient.kt | 77 +++++++++++++++ .../com/doctate/watch/net/UploadResult.kt | 11 +++ .../com/doctate/watch/settings/Settings.kt | 17 ++++ .../main/res/xml/network_security_config.xml | 14 +++ .../com/doctate/watch/domain/CaseIdTest.kt | 20 ++++ watch/wearos/gradle/libs.versions.toml | 17 ++++ 13 files changed, 335 insertions(+) create mode 100644 watch/wearos/app/src/androidTest/assets/sample.m4a create mode 100644 watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseId.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/net/HttpClientProvider.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/net/UploadClient.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/net/UploadResult.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/settings/Settings.kt create mode 100644 watch/wearos/app/src/main/res/xml/network_security_config.xml create mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseIdTest.kt diff --git a/watch/wearos/app/build.gradle.kts b/watch/wearos/app/build.gradle.kts index 935dea2..e3514d3 100644 --- a/watch/wearos/app/build.gradle.kts +++ b/watch/wearos/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -18,6 +20,22 @@ android { versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Build-time config sourced from local.properties (gitignored). + // Falls back to emulator loopback + sentinel key if absent. + val localProps = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() } + ?.inputStream()?.use { load(it) } + } + buildConfigField( + "String", "SERVER_URL", + "\"${localProps.getProperty("doctate.serverUrl", "http://10.0.2.2:3000")}\"" + ) + buildConfigField( + "String", "API_KEY", + "\"${localProps.getProperty("doctate.apiKey", "MISSING_API_KEY")}\"" + ) } buildTypes { @@ -36,6 +54,7 @@ android { useLibrary("wear-sdk") buildFeatures { compose = true + buildConfig = true } } @@ -55,4 +74,20 @@ dependencies { androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.test.manifest) debugImplementation(libs.ui.tooling) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + implementation(libs.okhttp) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.truth) + + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.okhttp.mockwebserver) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.kotlinx.coroutines.test) } \ No newline at end of file diff --git a/watch/wearos/app/src/androidTest/assets/sample.m4a b/watch/wearos/app/src/androidTest/assets/sample.m4a new file mode 100644 index 0000000000000000000000000000000000000000..f1deb50339df481bfd7d96bcf533eac79fcb4aa8 GIT binary patch literal 17581 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..4895442 --- /dev/null +++ b/watch/wearos/app/src/androidTest/java/com/doctate/watch/net/UploadClientTest.kt @@ -0,0 +1,91 @@ +package com.doctate.watch.net + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.doctate.watch.settings.Settings +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class UploadClientTest { + private lateinit var server: MockWebServer + private lateinit var client: UploadClient + private lateinit var audioFile: File + + @Before fun setUp() { + server = MockWebServer().apply { start() } + val settings = object : Settings { + override val serverUrl = server.url("/").toString().trimEnd('/') + override val apiKey = "key-dr_test" + } + client = UploadClient(OkHttpClient(), settings) + audioFile = copyAssetToTempFile("sample.m4a") + } + + @After fun tearDown() { + server.shutdown() + audioFile.delete() + } + + @Test fun upload_sends_correct_multipart_and_returns_success_on_200() = runTest { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"case_id":"550e8400-e29b-41d4-a716-446655440000","recorded_at":"2026-04-23T10:30:00Z","status":"received"}""" + ) + ) + + val result = client.upload( + caseId = "550e8400-e29b-41d4-a716-446655440000", + recordedAt = "2026-04-23T10:30:00Z", + audio = audioFile, + ) + + val req = server.takeRequest() + assertThat(req.method).isEqualTo("POST") + assertThat(req.path).isEqualTo("/api/upload") + assertThat(req.getHeader("X-API-Key")).isEqualTo("key-dr_test") + val body = req.body.readUtf8() + assertThat(body).contains("name=\"case_id\"") + assertThat(body).contains("550e8400-e29b-41d4-a716-446655440000") + assertThat(body).contains("name=\"recorded_at\"") + assertThat(body).contains("2026-04-23T10:30:00Z") + assertThat(body).contains("name=\"audio\"") + assertThat(body).contains("Content-Type: audio/mp4") + assertThat(result).isInstanceOf(UploadResult.Success::class.java) + } + + @Test fun upload_classifies_5xx_as_transient() = runTest { + server.enqueue(MockResponse().setResponseCode(500).setBody("oh no")) + val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile) + assertThat(result).isInstanceOf(UploadResult.Transient::class.java) + } + + @Test fun upload_classifies_401_as_terminal() = runTest { + server.enqueue(MockResponse().setResponseCode(401).setBody("nope")) + val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile) + assertThat(result).isInstanceOf(UploadResult.Terminal::class.java) + } + + @Test fun upload_classifies_io_error_as_transient() = runTest { + server.shutdown() + val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile) + assertThat(result).isInstanceOf(UploadResult.Transient::class.java) + } + + 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 -> + tmp.outputStream().use { output -> input.copyTo(output) } + } + return tmp + } +} diff --git a/watch/wearos/app/src/main/AndroidManifest.xml b/watch/wearos/app/src/main/AndroidManifest.xml index cc372df..bacb0db 100644 --- a/watch/wearos/app/src/main/AndroidManifest.xml +++ b/watch/wearos/app/src/main/AndroidManifest.xml @@ -2,13 +2,18 @@ + + + + classify(response.code, response.body?.string().orEmpty()) + } + } catch (e: IOException) { + UploadResult.Transient("network: ${e.message}") + } + } + + private fun classify(code: Int, body: String): UploadResult = when { + code in 200..299 -> parseAck(body) + code == 408 || code == 429 || code in 500..599 -> UploadResult.Transient("HTTP $code: $body") + else -> UploadResult.Terminal("HTTP $code: $body") + } + + private fun parseAck(body: String): UploadResult = try { + val json = JSONObject(body) + UploadResult.Success( + caseId = json.getString("case_id"), + recordedAt = json.getString("recorded_at"), + status = json.getString("status"), + ) + } catch (e: Exception) { + UploadResult.Terminal("parse ack: ${e.message}") + } + + companion object { + // Wire-protocol constants. Source of truth: doctate-common/src/constants.rs:1-14. + const val API_KEY_HEADER = "X-API-Key" + const val UPLOAD_PATH = "/api/upload" + const val FIELD_CASE_ID = "case_id" + const val FIELD_RECORDED_AT = "recorded_at" + const val FIELD_AUDIO = "audio" + const val CONTENT_TYPE_AUDIO_MP4 = "audio/mp4" + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/net/UploadResult.kt b/watch/wearos/app/src/main/java/com/doctate/watch/net/UploadResult.kt new file mode 100644 index 0000000..59e7d4e --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/net/UploadResult.kt @@ -0,0 +1,11 @@ +package com.doctate.watch.net + +/** + * Outcome of [UploadClient.upload]. Mirrors UploadOutcome in + * doctate-client-core/src/server_sync.rs (Succeeded / Transient / Terminal). + */ +sealed interface UploadResult { + data class Success(val caseId: String, val recordedAt: String, val status: String) : UploadResult + data class Transient(val reason: String) : UploadResult + data class Terminal(val reason: String) : UploadResult +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/settings/Settings.kt b/watch/wearos/app/src/main/java/com/doctate/watch/settings/Settings.kt new file mode 100644 index 0000000..645431d --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/settings/Settings.kt @@ -0,0 +1,17 @@ +package com.doctate.watch.settings + +import com.doctate.watch.BuildConfig + +/** + * Read-only access to runtime configuration. Future implementations (e.g. DataStore-backed + * for a user-facing settings screen) can replace [BuildConfigSettings] without touching consumers. + */ +interface Settings { + val serverUrl: String + val apiKey: String +} + +class BuildConfigSettings : Settings { + override val serverUrl: String = BuildConfig.SERVER_URL + override val apiKey: String = BuildConfig.API_KEY +} diff --git a/watch/wearos/app/src/main/res/xml/network_security_config.xml b/watch/wearos/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..555630d --- /dev/null +++ b/watch/wearos/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + + + + + + + 10.0.2.2 + + diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseIdTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseIdTest.kt new file mode 100644 index 0000000..af78c87 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseIdTest.kt @@ -0,0 +1,20 @@ +package com.doctate.watch.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CaseIdTest { + private val uuidV4Pattern = + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + + @Test fun new_returns_uuid_v4_string() { + val id = CaseId.new() + assertThat(id).matches(uuidV4Pattern) + } + + @Test fun new_returns_distinct_uuids() { + val a = CaseId.new() + val b = CaseId.new() + assertThat(a).isNotEqualTo(b) + } +} diff --git a/watch/wearos/gradle/libs.versions.toml b/watch/wearos/gradle/libs.versions.toml index 83e638b..3a408fa 100644 --- a/watch/wearos/gradle/libs.versions.toml +++ b/watch/wearos/gradle/libs.versions.toml @@ -9,6 +9,13 @@ composeUiTooling = "1.5.6" wearToolingPreview = "1.0.0" activityCompose = "1.8.0" coreSplashscreen = "1.2.0" +okhttp = "4.12.0" +coroutines = "1.8.1" +lifecycle = "2.8.6" +junit = "4.13.2" +truth = "1.4.4" +androidxTestRunner = "1.6.2" +androidxTestExtJunit = "1.2.1" [libraries] play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" } @@ -25,6 +32,16 @@ compose-ui-tooling = { group = "androidx.wear.compose", name = "compose-ui-tooli wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version.ref = "wearToolingPreview" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }