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
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
}
}