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 0000000..f1deb50 Binary files /dev/null and b/watch/wearos/app/src/androidTest/assets/sample.m4a differ 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" }