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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user