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.
This commit is contained in:
2026-04-23 15:25:53 +02:00
parent 673e0aeb9d
commit 8306dd8c47
13 changed files with 335 additions and 0 deletions
+35
View File
@@ -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)
}
Binary file not shown.
@@ -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
}
}
@@ -2,13 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.type.watch" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:name=".DoctateApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<uses-library
@@ -0,0 +1,18 @@
package com.doctate.watch
import android.app.Application
import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.UploadClient
import com.doctate.watch.settings.BuildConfigSettings
import com.doctate.watch.settings.Settings
import okhttp3.OkHttpClient
/**
* Application-level service locator. Singletons are exposed via lazy properties so
* consumers (ViewModelFactories, etc.) pull them through `(applicationContext as DoctateApp)`.
*/
class DoctateApp : Application() {
val settings: Settings by lazy { BuildConfigSettings() }
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
}
@@ -0,0 +1,13 @@
package com.doctate.watch.domain
import java.util.UUID
/**
* Each recording session generates a fresh case id. The server treats every unknown
* UUID as a new case directory (server/src/routes/upload.rs:71). Reusing a case id
* across recordings (e.g. caching) would silently merge unrelated dictations into
* one case.
*/
object CaseId {
fun new(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,17 @@
package com.doctate.watch.net
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Single shared OkHttpClient. OkHttp pools connections and threads internally;
* sharing one instance is the recommended pattern (creating new clients leaks pools).
*/
object HttpClientProvider {
fun create(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
@@ -0,0 +1,77 @@
package com.doctate.watch.net
import com.doctate.watch.settings.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONObject
import java.io.File
import java.io.IOException
/**
* POST a recorded audio file to the Doctate server. Wire format mirrors
* doctate-common/src/constants.rs and doctate-client-core/src/server_sync.rs:344-399.
* If the server contract changes, both this client and server/tests/upload_test.rs
* fail independently — that coupling is intentional.
*/
class UploadClient(
private val http: OkHttpClient,
private val settings: Settings,
) {
suspend fun upload(caseId: String, recordedAt: String, audio: File): UploadResult =
withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(FIELD_CASE_ID, caseId)
.addFormDataPart(FIELD_RECORDED_AT, recordedAt)
.addFormDataPart(
FIELD_AUDIO,
audio.name,
audio.asRequestBody(CONTENT_TYPE_AUDIO_MP4.toMediaType()),
)
.build()
val request = Request.Builder()
.url(settings.serverUrl.trimEnd('/') + UPLOAD_PATH)
.header(API_KEY_HEADER, settings.apiKey)
.post(body)
.build()
try {
http.newCall(request).execute().use { response ->
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"
}
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Allow cleartext only for the Android emulator's host loopback,
where the local Axum dev server runs. Production HTTPS targets
remain locked down by base-config. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -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)
}
}
+17
View File
@@ -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" }