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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
@@ -18,6 +20,22 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
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 {
|
buildTypes {
|
||||||
@@ -36,6 +54,7 @@ android {
|
|||||||
useLibrary("wear-sdk")
|
useLibrary("wear-sdk")
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,4 +74,20 @@ dependencies {
|
|||||||
androidTestImplementation(libs.ui.test.junit4)
|
androidTestImplementation(libs.ui.test.junit4)
|
||||||
debugImplementation(libs.ui.test.manifest)
|
debugImplementation(libs.ui.test.manifest)
|
||||||
debugImplementation(libs.ui.tooling)
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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.type.watch" />
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".DoctateApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.DeviceDefault">
|
android:theme="@android:style/Theme.DeviceDefault">
|
||||||
<uses-library
|
<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"
|
wearToolingPreview = "1.0.0"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
coreSplashscreen = "1.2.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]
|
[libraries]
|
||||||
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
|
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" }
|
wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version.ref = "wearToolingPreview" }
|
||||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user