feat(watch): replace CaseStoreStub with disk-backed CaseStore

Per-case JSON markers under filesDir/recordings/cases/, atomic tmp+rename
writes, kotlinx.coroutines Mutex serialising mutations. Wire format mirrors
doctate-client-core::CaseMarker exactly (snake_case keys, RFC3339 strings,
internally-tagged OnelinerState) — markers round-trip with the desktop client.

mergeServerSnapshot adds/updates only; reconcileWithServerSnapshot removes
synced markers inside the response window that the server stopped reporting.
Pending markers are never touched. Local Manual oneliners stick against
non-Manual server overrides.

DoctateApp now exposes caseStore as a lazy property and wires a
distinct-head flow collector to TileService.requestUpdate, decoupling
the store from Android Tile APIs (testable on the JVM).

JVM test infra: real org.json:json:20240303 plus
testOptions.unitTests.isReturnDefaultValues=true to bypass android.jar
'not mocked' stubs for Log/JSONObject. 35 unit tests green incl. 13 new
DiskCaseStoreTest cases (bootstrap, merge/reconcile semantics, mutex
ordering).
This commit is contained in:
2026-04-26 23:35:00 +02:00
parent 6d47b6b99b
commit 24644a0144
15 changed files with 840 additions and 232 deletions
+8
View File
@@ -64,6 +64,12 @@ android {
compose = true
buildConfig = true
}
testOptions {
// Stub Android-framework methods (Log.i, etc.) return defaults instead of
// throwing "not mocked" — required for JVM unit tests of code that uses
// android.util.Log alongside core java APIs.
unitTests.isReturnDefaultValues = true
}
}
dependencies {
@@ -100,6 +106,8 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.truth)
// Real org.json so JVM unit tests don't hit the "not mocked" stub from android.jar.
testImplementation("org.json:json:20240303")
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
@@ -1,14 +1,24 @@
package com.doctate.watch
import android.app.Application
import com.doctate.watch.domain.CaseStoreStub
import android.util.Log
import androidx.wear.tiles.TileService
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.DiskCaseStore
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 com.doctate.watch.tile.DoctateTileService
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.launchIn
import okhttp3.OkHttpClient
/**
@@ -20,6 +30,11 @@ class DoctateApp : Application() {
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
/** Persistent disk-backed marker store. Single source of truth for the case list. */
val caseStore: CaseStore by lazy {
DiskCaseStore(File(filesDir, "recordings/cases"))
}
/** Process-scoped coroutine scope for work that must outlive any single ViewModel
* (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
val applicationScope: CoroutineScope by lazy {
@@ -28,7 +43,20 @@ class DoctateApp : Application() {
override fun onCreate() {
super.onCreate()
CaseStoreStub.attach(this)
CaseStoreStub.seedDemoData()
// Trigger a Tile refresh whenever the rendered head of the case list
// changes — Tile shows currentCase, only the head matters.
caseStore.casesFlow
.map { it.firstOrNull()?.let { e -> Triple(e.caseId, e.lastActivityAt, e.oneliner) } }
.distinctUntilChanged()
.drop(1) // initial emission is the bootstrap snapshot, no refresh needed
.onEach {
Log.i(TAG, "tile refresh requested: head changed")
TileService.getUpdater(this).requestUpdate(DoctateTileService::class.java)
}
.launchIn(applicationScope)
}
private companion object {
const val TAG = "DoctateApp"
}
}
@@ -0,0 +1,85 @@
package com.doctate.watch.domain
import org.json.JSONObject
import java.time.Instant
/**
* On-disk representation of a single case. Wire-format identical to
* `doctate-client-core::CaseMarker` (Rust) — same field names (snake_case),
* same RFC3339 timestamps, same internally-tagged OnelinerState. A marker
* file written by either client should round-trip through the other.
*
* Kept disjoint from [CaseEntry] so the in-memory UI representation can
* use Long epoch-ms (faster to format) without dragging the wire format
* along. Conversion happens in [DiskCaseStore] at the IO boundary.
*/
internal data class CaseMarker(
val caseId: String,
val createdAtRfc: String,
val lastActivityAtRfc: String,
val syncedToServer: Boolean,
val oneliner: OnelinerState?,
) {
fun toJson(): String = JSONObject().apply {
put("case_id", caseId)
put("created_at", createdAtRfc)
put("last_activity_at", lastActivityAtRfc)
put("synced_to_server", syncedToServer)
put("oneliner", oneliner?.let { onelinerToJson(it) } ?: JSONObject.NULL)
}.toString(2)
fun toEntry(): CaseEntry = CaseEntry(
caseId = caseId,
createdAt = Instant.parse(createdAtRfc).toEpochMilli(),
lastActivityAt = Instant.parse(lastActivityAtRfc).toEpochMilli(),
oneliner = oneliner,
syncedToServer = syncedToServer,
)
companion object {
fun fromJson(text: String): CaseMarker {
val obj = JSONObject(text)
return CaseMarker(
caseId = obj.getString("case_id"),
createdAtRfc = obj.getString("created_at"),
lastActivityAtRfc = obj.getString("last_activity_at"),
syncedToServer = obj.optBoolean("synced_to_server", false),
oneliner = obj.optJSONObject("oneliner")?.let { onelinerFromJson(it) },
)
}
}
}
/** Encode `OnelinerState` as the same internally-tagged JSON the server emits. */
internal fun onelinerToJson(state: OnelinerState): JSONObject = when (state) {
is OnelinerState.Ready -> JSONObject()
.put("kind", "ready")
.put("text", state.text)
.put("generated_at", state.generatedAt)
is OnelinerState.Empty -> JSONObject()
.put("kind", "empty")
.put("generated_at", state.generatedAt)
is OnelinerState.Error -> JSONObject()
.put("kind", "error")
.put("generated_at", state.generatedAt)
is OnelinerState.Manual -> JSONObject()
.put("kind", "manual")
.put("text", state.text)
.put("set_at", state.setAt)
}
/** Decode an internally-tagged `OnelinerState`. Unknown kinds raise IllegalArgumentException. */
internal fun onelinerFromJson(obj: JSONObject): OnelinerState =
when (val kind = obj.getString("kind")) {
"ready" -> OnelinerState.Ready(
text = obj.getString("text"),
generatedAt = obj.getString("generated_at"),
)
"empty" -> OnelinerState.Empty(generatedAt = obj.getString("generated_at"))
"error" -> OnelinerState.Error(generatedAt = obj.getString("generated_at"))
"manual" -> OnelinerState.Manual(
text = obj.getString("text"),
setAt = obj.getString("set_at"),
)
else -> throw IllegalArgumentException("unknown OnelinerState kind: $kind")
}
@@ -0,0 +1,55 @@
package com.doctate.watch.domain
import kotlinx.coroutines.flow.StateFlow
/**
* Per-case marker store. Single source of truth for the case list shown in
* Activity, Tile and Complication. Mirrors `doctate-client-core::CaseStore`
* (Rust) — the asymmetric merge/reconcile design is the load-bearing piece:
*
* - [mergeServerSnapshot] only adds/updates. `/api/oneliners` is a sliding
* window, so "missing" means nothing on its own.
* - [reconcileWithServerSnapshot] removes synced markers the server no
* longer reports, scoped to the window it covers. Pending markers are
* never touched — losing one would silently erase a recording.
*/
interface CaseStore {
/** Reactive snapshot, sorted by `lastActivityAt` desc. */
val casesFlow: StateFlow<List<CaseEntry>>
/** Snapshot accessor for non-suspending contexts (Tile, Complication). */
val currentCase: CaseEntry? get() = casesFlow.value.firstOrNull()
/** Append a new case the client itself just started. Returns the new id. */
suspend fun createLocal(now: Long = System.currentTimeMillis()): String
/** Bump `lastActivityAt`. Creates the marker if missing. */
suspend fun markActivity(caseId: String, now: Long = System.currentTimeMillis())
/** Flip `syncedToServer` to true after a successful upload ACK. No-op if absent. */
suspend fun markSynced(caseId: String)
/** Apply a doctor's manual oneliner override locally (optimistic UI). */
suspend fun setOnelinerLocal(caseId: String, state: OnelinerState)
/**
* Apply a server `/api/oneliners` response: insert/update each entry,
* never delete. Server's oneliner + timestamps win, except
* `lastActivityAt` keeps whichever (local vs. server) is newer, and a
* locally-set `Manual` oneliner sticks against non-Manual server states.
*/
suspend fun mergeServerSnapshot(response: OnelinersResponse)
/**
* Remove synced markers inside the response's window that the server
* no longer lists. Returns count removed. Pending markers and markers
* outside the window are preserved.
*/
suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int
/** Drop synced markers older than `cutoffMs`. Returns count removed. */
suspend fun cleanupStale(cutoffMs: Long): Int
/** Wipe everything (config change, account switch). */
suspend fun clearAll()
}
@@ -1,122 +0,0 @@
package com.doctate.watch.domain
import android.content.Context
import android.util.Log
import androidx.wear.tiles.TileService
import com.doctate.watch.tile.DoctateTileService
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* In-memory stand-in for a future Kotlin port of the Rust `CaseStore`.
*
* All three Wear OS surfaces (Activity list, Tile, Complication) read from
* this singleton so they never diverge. The store is intentionally flat —
* no disk persistence, no server merge, no sync flags. When the process
* dies the state is gone; the PoC seeds a few fake cases on app start to
* keep the UI populated.
*
* Invariant: `_cases.value` is always sorted by `lastActivityAt` desc, so
* `value.firstOrNull()` is the "current case" for the Tile.
*
* The [appContext] must be set via [attach] from [com.doctate.watch.DoctateApp.onCreate]
* so every mutation can trigger a Tile refresh. When context is null (e.g. JVM unit
* tests), the refresh step is silently skipped.
*/
object CaseStoreStub {
private const val TAG = "CaseStoreStub"
private val _cases = MutableStateFlow<List<CaseEntry>>(emptyList())
val cases: StateFlow<List<CaseEntry>> = _cases.asStateFlow()
/** Snapshot accessor for non-suspending contexts (e.g. `TileService.onTileRequest`). */
val currentCase: CaseEntry? get() = _cases.value.firstOrNull()
private var appContext: Context? = null
fun attach(context: Context) {
appContext = context.applicationContext
}
fun createLocal(now: Long = System.currentTimeMillis()): String {
val id = CaseId.new()
val entry = CaseEntry(
caseId = id,
createdAt = now,
lastActivityAt = now,
oneliner = null,
)
_cases.update { listOf(entry) + it }
requestTileRefresh("createLocal id=${id.take(8)}")
return id
}
fun markActivity(caseId: String, now: Long = System.currentTimeMillis()) {
_cases.update { current ->
val idx = current.indexOfFirst { it.caseId == caseId }
if (idx < 0) return@update current
val updated = current[idx].copy(lastActivityAt = now)
(listOf(updated) + current.filterIndexed { i, _ -> i != idx })
}
requestTileRefresh("markActivity id=${caseId.take(8)}")
}
/**
* Update a case's oneliner. A previously-set Manual override sticks against
* any non-Manual update — the doctor's text wins until they overwrite it
* with another Manual.
*/
fun setOneliner(caseId: String, state: OnelinerState) {
_cases.update { current ->
current.map {
when {
it.caseId != caseId -> it
it.oneliner is OnelinerState.Manual && state !is OnelinerState.Manual -> it
else -> it.copy(oneliner = state)
}
}
}
requestTileRefresh("setOneliner id=${caseId.take(8)} kind=${state::class.simpleName}")
}
fun seedDemoData(now: Long = System.currentTimeMillis()) {
if (_cases.value.isNotEmpty()) return
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
fun at(date: LocalDate, hour: Int, minute: Int): Long =
date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
fun minutesAgo(m: Long): Long = now - m * 60_000L
fun ready(text: String): OnelinerState = OnelinerState.Ready(text, Instant.now().toString())
// Covers all label variants: "Vor 1 Minute" / "Vor X Minuten" / HH:mm /
// "Gestern - HH:mm" / "dd.MM.yy - HH:mm". `sortedByDescending` keeps the
// store invariant (newest first) intact regardless of the current time of day.
val entries = listOf(
CaseEntry(CaseId.new(), minutesAgo(1), minutesAgo(1), ready("Anamnese Herr Weber")),
CaseEntry(CaseId.new(), minutesAgo(32), minutesAgo(32), ready("Kniegelenksarthroskopie rechts")),
CaseEntry(CaseId.new(), at(today, 8, 15), at(today, 8, 15), ready("Befund Thorax unauffällig")),
CaseEntry(CaseId.new(), at(today.minusDays(1), 16, 40), at(today.minusDays(1), 16, 40), ready("Postoperative Visite Zimmer 12")),
CaseEntry(CaseId.new(), at(today.minusDays(3), 11, 20), at(today.minusDays(3), 11, 20), ready("MRT Schulter links")),
CaseEntry(CaseId.new(), at(today.minusDays(8), 9, 5), at(today.minusDays(8), 9, 5), ready("Laborkontrolle Diabetes")),
)
_cases.value = entries.sortedByDescending { it.lastActivityAt }
requestTileRefresh("seedDemoData")
}
private fun requestTileRefresh(reason: String) {
val ctx = appContext ?: return
Log.i(TAG, "requestTileRefresh: $reason")
TileService.getUpdater(ctx).requestUpdate(DoctateTileService::class.java)
}
/** Test-only: wipe state between tests. `object` singletons leak across tests otherwise. */
internal fun resetForTest() {
_cases.value = emptyList()
appContext = null
}
}
@@ -0,0 +1,239 @@
package com.doctate.watch.domain
import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Disk-backed [CaseStore]. One JSON file per case under [dir]; in-memory
* map serialised by [mutex] so concurrent merges from the sync worker and
* the UI can't collide on a write.
*
* Atomic writes via temp-file + `Files.move(... ATOMIC_MOVE, REPLACE_EXISTING)`
* — same trick as `case_store.rs::write_marker_file`. Readers either see
* the previous version or the new one, never a partial blob.
*
* Bootstrap is synchronous (constructor body): the watch can render the
* cached list before any coroutine has run. Empty/missing dir is fine —
* the store starts empty and creates the dir lazily on first write.
*/
class DiskCaseStore(private val dir: File) : CaseStore {
private val state = LinkedHashMap<String, CaseMarker>()
private val mutex = Mutex()
private val _flow = MutableStateFlow<List<CaseEntry>>(emptyList())
override val casesFlow: StateFlow<List<CaseEntry>> = _flow.asStateFlow()
init {
bootstrap()
}
private fun bootstrap() {
if (!dir.exists()) {
dir.mkdirs()
return
}
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
for (f in files) {
try {
val marker = CaseMarker.fromJson(f.readText(Charsets.UTF_8))
state[marker.caseId] = marker
} catch (e: Exception) {
// A single unreadable file shouldn't poison the store.
Log.w(TAG, "skipping unreadable marker ${f.name}: ${e.message}")
}
}
broadcast()
}
override suspend fun createLocal(now: Long): String {
val id = CaseId.new()
val nowRfc = epochMsToRfc(now)
val marker = CaseMarker(
caseId = id,
createdAtRfc = nowRfc,
lastActivityAtRfc = nowRfc,
syncedToServer = false,
oneliner = null,
)
mutex.withLock {
writeMarkerFile(marker)
state[id] = marker
broadcast()
}
Log.i(TAG, "createLocal id=${id.take(8)}")
return id
}
override suspend fun markActivity(caseId: String, now: Long) {
val nowRfc = epochMsToRfc(now)
mutex.withLock {
val existing = state[caseId]
val updated = existing?.copy(lastActivityAtRfc = nowRfc)
?: CaseMarker(
caseId = caseId,
createdAtRfc = nowRfc,
lastActivityAtRfc = nowRfc,
syncedToServer = false,
oneliner = null,
)
writeMarkerFile(updated)
state[caseId] = updated
broadcast()
}
}
override suspend fun markSynced(caseId: String) {
mutex.withLock {
val existing = state[caseId] ?: return
if (existing.syncedToServer) return
val updated = existing.copy(syncedToServer = true)
writeMarkerFile(updated)
state[caseId] = updated
broadcast()
}
Log.i(TAG, "markSynced id=${caseId.take(8)}")
}
override suspend fun setOnelinerLocal(caseId: String, state: OnelinerState) {
mutex.withLock {
val existing = this.state[caseId] ?: return
val updated = existing.copy(oneliner = state)
writeMarkerFile(updated)
this.state[caseId] = updated
broadcast()
}
}
override suspend fun mergeServerSnapshot(response: OnelinersResponse) {
mutex.withLock {
for (entry in response.oneliners) {
val serverActivityRfc = entry.lastRecordingAt
?: entry.updatedAt
?: entry.createdAt
val existing = state[entry.caseId]
val updated = if (existing != null) {
// A locally-set Manual oneliner sticks against non-Manual server overrides.
val keepLocalManual = existing.oneliner is OnelinerState.Manual &&
entry.oneliner !is OnelinerState.Manual
existing.copy(
syncedToServer = true,
oneliner = if (keepLocalManual) existing.oneliner else entry.oneliner,
lastActivityAtRfc = if (serverActivityRfc > existing.lastActivityAtRfc) {
serverActivityRfc
} else existing.lastActivityAtRfc,
)
} else {
CaseMarker(
caseId = entry.caseId,
createdAtRfc = entry.createdAt,
lastActivityAtRfc = serverActivityRfc,
syncedToServer = true,
oneliner = entry.oneliner,
)
}
writeMarkerFile(updated)
state[entry.caseId] = updated
}
broadcast()
}
}
override suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int {
// Window anchor: as_of - window_hours, lexicographically comparable to RFC3339.
val asOfMs = Instant.parse(response.asOf).toEpochMilli()
val windowStartMs = asOfMs - response.windowHours * 3_600_000L
val windowStartRfc = epochMsToRfc(windowStartMs)
val serverIds = response.oneliners.map { it.caseId }.toHashSet()
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { m ->
m.syncedToServer &&
m.lastActivityAtRfc >= windowStartRfc &&
m.caseId !in serverIds
}.map { it.caseId }
for (id in toRemove) {
deleteMarkerFile(id)
state.remove(id)
Log.i(TAG, "marker removed (reconcile: missing from server snapshot) id=${id.take(8)}")
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun cleanupStale(cutoffMs: Long): Int {
val cutoffRfc = epochMsToRfc(cutoffMs)
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { m ->
m.syncedToServer && m.lastActivityAtRfc < cutoffRfc
}.map { it.caseId }
for (id in toRemove) {
deleteMarkerFile(id)
state.remove(id)
Log.i(TAG, "stale marker removed id=${id.take(8)} cutoff=$cutoffRfc")
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun clearAll() {
mutex.withLock {
val files = dir.listFiles { f -> f.name.endsWith(".json") } ?: emptyArray()
for (f in files) {
if (!f.delete()) Log.w(TAG, "failed to delete marker ${f.name} during clearAll")
}
state.clear()
broadcast()
}
}
private fun broadcast() {
// Sort newest-first so .firstOrNull() yields the "current case".
val list = state.values
.map { it.toEntry() }
.sortedByDescending { it.lastActivityAt }
_flow.value = list
}
private fun writeMarkerFile(marker: CaseMarker) {
if (!dir.exists()) dir.mkdirs()
val finalPath = File(dir, "${marker.caseId}.json")
val tmpPath = File(dir, "${marker.caseId}.json.tmp")
tmpPath.writeText(marker.toJson(), Charsets.UTF_8)
Files.move(
tmpPath.toPath(),
finalPath.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
private fun deleteMarkerFile(caseId: String) {
val f = File(dir, "$caseId.json")
if (f.exists() && !f.delete()) {
Log.w(TAG, "failed to delete marker $caseId.json")
}
}
companion object {
private const val TAG = "DiskCaseStore"
/** Epoch-ms → RFC3339 UTC string with `Z` suffix (no offset). */
fun epochMsToRfc(ms: Long): String = Instant.ofEpochMilli(ms).toString()
}
}
@@ -0,0 +1,24 @@
package com.doctate.watch.domain
/**
* Wire-format DTOs for `GET /api/oneliners`. Mirror
* `doctate-common/src/oneliners.rs::OnelinersResponse` and `OnelinerEntry`
* exactly; the `OnelinersClient` (Phase 6) parses JSON into these.
*
* Timestamps stay RFC3339 strings — lexicographic comparison on the Zulu
* format is correctly ordered, which is the same trick the Rust client uses
* for window-anchored reconciliation.
*/
data class OnelinersResponse(
val asOf: String,
val windowHours: Int,
val oneliners: List<OnelinerEntry>,
)
data class OnelinerEntry(
val caseId: String,
val oneliner: OnelinerState?,
val createdAt: String,
val lastRecordingAt: String?,
val updatedAt: String?,
)
@@ -3,6 +3,8 @@ package com.doctate.watch.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.navArgument
@@ -10,9 +12,10 @@ import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.DoctateApp
import com.doctate.watch.presentation.theme.DoctateWatchTheme
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
object Routes {
const val LIST = "list"
@@ -26,6 +29,8 @@ object Routes {
fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
val navController = rememberSwipeDismissableNavController()
val pending by pendingCommand.collectAsStateWithLifecycle()
val app = LocalContext.current.applicationContext as DoctateApp
val coroutineScope = rememberCoroutineScope()
// Key on seq so repeated identical commands still fire.
LaunchedEffect(pending.seq) {
@@ -36,7 +41,7 @@ fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
navController.popBackStack(Routes.LIST, inclusive = false)
}
NavCommand.NewCase -> {
val id = CaseStoreStub.createLocal()
val id = app.caseStore.createLocal()
navController.navigate(Routes.recording(id))
}
is NavCommand.ContinueCase -> {
@@ -57,8 +62,10 @@ fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
navController.navigate(Routes.detail(caseId))
},
onNewCase = {
val id = CaseStoreStub.createLocal()
coroutineScope.launch {
val id = app.caseStore.createLocal()
navController.navigate(Routes.recording(id))
}
},
)
}
@@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -31,10 +33,11 @@ import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.input.RemoteInputIntentHelper
import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.DoctateApp
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.displayText
import java.time.Instant
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -55,8 +58,10 @@ fun CaseDetailScreen(
caseId: String,
onRecord: () -> Unit,
) {
val cases by CaseStoreStub.cases.collectAsStateWithLifecycle()
val app = LocalContext.current.applicationContext as DoctateApp
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
val case = cases.firstOrNull { it.caseId == caseId }
val coroutineScope = rememberCoroutineScope()
val editLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
@@ -69,12 +74,14 @@ fun CaseDetailScreen(
?.trim()
.orEmpty()
if (text.isNotEmpty()) {
CaseStoreStub.setOneliner(
coroutineScope.launch {
app.caseStore.setOnelinerLocal(
caseId,
OnelinerState.Manual(text, setAt = Instant.now().toString()),
)
}
}
}
val launchEdit: () -> Unit = {
val languageExtras = Bundle().apply {
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -25,8 +26,8 @@ import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.doctate.watch.DoctateApp
import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.domain.displayText
@Composable
@@ -34,7 +35,8 @@ fun CaseListScreen(
onCaseTap: (caseId: String) -> Unit,
onNewCase: () -> Unit,
) {
val cases by CaseStoreStub.cases.collectAsStateWithLifecycle()
val app = LocalContext.current.applicationContext as DoctateApp
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
val listState = rememberScalingLazyListState()
// Display oldest-first so the newest entry sits next to the `Neu` EdgeButton.
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.AudioRecorder
import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.net.UploadClient
import com.doctate.watch.net.UploadResult
@@ -32,6 +32,7 @@ class RecordingViewModel(
private val caseId: String,
private val recorder: AudioRecorder,
private val uploadClient: UploadClient,
private val caseStore: CaseStore,
private val applicationScope: CoroutineScope,
) : ViewModel() {
@@ -128,7 +129,8 @@ class RecordingViewModel(
try {
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
is UploadResult.Success -> {
CaseStoreStub.markActivity(caseId)
caseStore.markActivity(caseId)
caseStore.markSynced(caseId)
triggerFakeOnelinerBurst(caseId)
dispatch(RecordingEvent.UploadSucceeded(caseId))
}
@@ -155,7 +157,7 @@ class RecordingViewModel(
applicationScope.launch {
delay(2_000)
val label = fakeLabelFormat.format(Date())
CaseStoreStub.setOneliner(
caseStore.setOnelinerLocal(
caseId,
OnelinerState.Ready("[PoC] OneLiner $label", Instant.now().toString()),
)
@@ -178,6 +180,7 @@ class RecordingViewModel(
caseId = caseId,
recorder = AudioRecorder(app),
uploadClient = app.uploadClient,
caseStore = app.caseStore,
applicationScope = app.applicationScope,
)
}
@@ -6,22 +6,25 @@ import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
import androidx.wear.tiles.TileService
import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.DoctateApp
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
private const val RESOURCES_VERSION = "1"
/**
* Serves the single Doctate Tile. Reads a non-blocking snapshot from [CaseStoreStub]
* and renders the "glance at current case" view with three tap regions. Refreshes
* are triggered externally via `TileService.getUpdater(ctx).requestUpdate(...)`.
* Serves the single Doctate Tile. Reads a non-blocking snapshot from
* [DoctateApp.caseStore] and renders the "glance at current case" view with
* three tap regions. Refreshes are triggered externally via
* `TileService.getUpdater(ctx).requestUpdate(...)` — the App wires that up
* to the case-store flow.
*/
class DoctateTileService : TileService() {
override fun onTileRequest(
requestParams: RequestBuilders.TileRequest,
): ListenableFuture<TileBuilders.Tile> {
val current = CaseStoreStub.currentCase
val app = applicationContext as DoctateApp
val current = app.caseStore.currentCase
val layout = LayoutElementBuilders.Layout.Builder()
.setRoot(buildTileLayout(packageName, current))
.build()
@@ -1,87 +0,0 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import java.time.Instant
import org.junit.Before
import org.junit.Test
class CaseStoreStubTest {
@Before fun setUp() {
CaseStoreStub.resetForTest()
}
@Test fun createLocal_inserts_entry_at_head() {
val id = CaseStoreStub.createLocal(now = 1_000L)
val snapshot = CaseStoreStub.cases.value
assertThat(snapshot).hasSize(1)
assertThat(snapshot[0].caseId).isEqualTo(id)
assertThat(snapshot[0].oneliner).isNull()
assertThat(snapshot[0].syncedToServer).isFalse()
}
@Test fun createLocal_returns_new_case_at_head_of_list() {
val first = CaseStoreStub.createLocal(now = 1_000L)
val second = CaseStoreStub.createLocal(now = 2_000L)
val snapshot = CaseStoreStub.cases.value
assertThat(snapshot.map { it.caseId }).containsExactly(second, first).inOrder()
}
@Test fun markActivity_brings_older_case_to_front() {
val first = CaseStoreStub.createLocal(now = 1_000L)
val second = CaseStoreStub.createLocal(now = 2_000L)
CaseStoreStub.markActivity(first, now = 3_000L)
val snapshot = CaseStoreStub.cases.value
assertThat(snapshot.map { it.caseId }).containsExactly(first, second).inOrder()
assertThat(snapshot[0].lastActivityAt).isEqualTo(3_000L)
}
@Test fun markActivity_ignores_unknown_caseId() {
val id = CaseStoreStub.createLocal(now = 1_000L)
CaseStoreStub.markActivity("does-not-exist", now = 9_000L)
val snapshot = CaseStoreStub.cases.value
assertThat(snapshot).hasSize(1)
assertThat(snapshot[0].caseId).isEqualTo(id)
assertThat(snapshot[0].lastActivityAt).isEqualTo(1_000L)
}
@Test fun setOneliner_mutates_target_case_only() {
val a = CaseStoreStub.createLocal(now = 1_000L)
val b = CaseStoreStub.createLocal(now = 2_000L)
CaseStoreStub.setOneliner(a, ready("Test-Oneliner"))
val snapshot = CaseStoreStub.cases.value
val ready = snapshot.first { it.caseId == a }.oneliner as OnelinerState.Ready
assertThat(ready.text).isEqualTo("Test-Oneliner")
assertThat(snapshot.first { it.caseId == b }.oneliner).isNull()
}
@Test fun setOneliner_manual_blocks_subsequent_auto_update() {
val id = CaseStoreStub.createLocal(now = 1_000L)
CaseStoreStub.setOneliner(id, OnelinerState.Manual("Doktor-Text", setAt = nowRfc3339()))
CaseStoreStub.setOneliner(id, ready("[PoC] Auto-Text"))
val entry = CaseStoreStub.cases.value.first { it.caseId == id }
val manual = entry.oneliner as OnelinerState.Manual
assertThat(manual.text).isEqualTo("Doktor-Text")
}
@Test fun setOneliner_manual_overwrites_previous_manual() {
val id = CaseStoreStub.createLocal(now = 1_000L)
CaseStoreStub.setOneliner(id, OnelinerState.Manual("V1", setAt = nowRfc3339()))
CaseStoreStub.setOneliner(id, OnelinerState.Manual("V2", setAt = nowRfc3339()))
val entry = CaseStoreStub.cases.value.first { it.caseId == id }
val manual = entry.oneliner as OnelinerState.Manual
assertThat(manual.text).isEqualTo("V2")
}
@Test fun currentCase_returns_head_of_list() {
assertThat(CaseStoreStub.currentCase).isNull()
val first = CaseStoreStub.createLocal(now = 1_000L)
assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(first)
val second = CaseStoreStub.createLocal(now = 2_000L)
assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(second)
}
private fun ready(text: String): OnelinerState =
OnelinerState.Ready(text, generatedAt = nowRfc3339())
private fun nowRfc3339(): String = Instant.now().toString()
}
@@ -0,0 +1,209 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import java.io.File
import java.time.Instant
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class DiskCaseStoreTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newStore(subdir: String = "cases"): DiskCaseStore =
DiskCaseStore(File(tempDir.root, subdir))
@Test fun createLocal_persists_marker_file_and_emits_snapshot() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
val list = store.casesFlow.value
assertThat(list).hasSize(1)
assertThat(list[0].caseId).isEqualTo(id)
assertThat(list[0].syncedToServer).isFalse()
val markerFile = File(tempDir.root, "cases/$id.json")
assertThat(markerFile.exists()).isTrue()
val parsed = CaseMarker.fromJson(markerFile.readText())
assertThat(parsed.caseId).isEqualTo(id)
assertThat(parsed.syncedToServer).isFalse()
}
@Test fun bootstrap_reloads_markers_from_disk() = runTest {
val s1 = newStore()
val a = s1.createLocal(now = 1_700_000_000_000L)
val b = s1.createLocal(now = 1_700_000_001_000L)
// Build a fresh store on the same dir → must repopulate from disk.
val s2 = newStore()
val ids = s2.casesFlow.value.map { it.caseId }.toSet()
assertThat(ids).containsExactly(a, b)
}
@Test fun markActivity_creates_marker_if_missing() = runTest {
val store = newStore()
store.markActivity("11111111-1111-1111-1111-111111111111", now = 1_700_000_000_000L)
val list = store.casesFlow.value
assertThat(list).hasSize(1)
assertThat(list[0].caseId).isEqualTo("11111111-1111-1111-1111-111111111111")
}
@Test fun markSynced_sets_flag_and_persists() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
store.markSynced(id)
assertThat(store.casesFlow.value.first { it.caseId == id }.syncedToServer).isTrue()
// Persisted?
val parsed = CaseMarker.fromJson(File(tempDir.root, "cases/$id.json").readText())
assertThat(parsed.syncedToServer).isTrue()
}
@Test fun mergeServerSnapshot_inserts_unknown_case_with_synced_true() = runTest {
val store = newStore()
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = "case-aaaa",
oneliner = OnelinerState.Ready("hi", generatedAt = "2026-04-26T11:55:00Z"),
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = "2026-04-26T11:30:00Z",
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
assertThat(entry.caseId).isEqualTo("case-aaaa")
assertThat(entry.syncedToServer).isTrue()
assertThat((entry.oneliner as OnelinerState.Ready).text).isEqualTo("hi")
}
@Test fun mergeServerSnapshot_keeps_local_manual_against_non_manual_server() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
store.setOnelinerLocal(id, OnelinerState.Manual("doc-text", setAt = "2026-04-26T11:00:00Z"))
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = id,
oneliner = OnelinerState.Ready("server-text", generatedAt = "2026-04-26T11:30:00Z"),
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = null,
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
val manual = entry.oneliner as OnelinerState.Manual
assertThat(manual.text).isEqualTo("doc-text")
}
@Test fun mergeServerSnapshot_keeps_newer_local_lastActivity() = runTest {
val store = newStore()
// Local activity at T+10s; server reports T+5s — local wins.
val laterMs = Instant.parse("2026-04-26T11:00:10Z").toEpochMilli()
val id = "case-bbbb"
store.markActivity(id, now = laterMs)
store.markSynced(id)
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = id,
oneliner = null,
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = "2026-04-26T11:00:05Z",
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
assertThat(entry.lastActivityAt).isEqualTo(laterMs)
}
@Test fun reconcile_removes_synced_marker_inside_window_missing_from_server() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = "case-cccc"
store.markActivity(id, now = asOfMs - 3_600_000L) // 1h ago, well inside 72h window
store.markSynced(id)
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(1)
assertThat(store.casesFlow.value).isEmpty()
// marker file gone
assertThat(File(tempDir.root, "cases/$id.json").exists()).isFalse()
}
@Test fun reconcile_preserves_pending_marker() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = store.createLocal(now = asOfMs - 3_600_000L) // pending: synced=false
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(0)
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(id)
}
@Test fun reconcile_preserves_marker_outside_window() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = "case-old"
// 80h ago — outside 72h window: server silence not authoritative.
store.markActivity(id, now = asOfMs - 80 * 3_600_000L)
store.markSynced(id)
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(0)
}
@Test fun cleanupStale_drops_only_synced() = runTest {
val store = newStore()
val cutoff = 2_000_000L
val olderSynced = "case-old-synced"
val olderPending = "case-old-pending"
store.markActivity(olderSynced, now = 1_000_000L)
store.markSynced(olderSynced)
store.markActivity(olderPending, now = 1_000_000L)
val removed = store.cleanupStale(cutoffMs = cutoff)
assertThat(removed).isEqualTo(1)
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(olderPending)
}
@Test fun clearAll_wipes_memory_and_disk() = runTest {
val store = newStore()
store.createLocal()
store.createLocal()
store.clearAll()
assertThat(store.casesFlow.value).isEmpty()
val files = File(tempDir.root, "cases").listFiles()?.toList().orEmpty()
assertThat(files).isEmpty()
}
@Test fun concurrent_creates_serialize_via_mutex() = runTest {
val store = newStore()
val ids = (1..10).map {
async { store.createLocal(now = 1_700_000_000_000L + it) }
}.awaitAll()
// Each create wrote one file; size matches.
assertThat(store.casesFlow.value.map { it.caseId }.toSet()).hasSize(ids.size)
val onDisk = File(tempDir.root, "cases").listFiles()?.filter { it.name.endsWith(".json") }
?.map { it.nameWithoutExtension } ?: emptyList()
assertThat(onDisk.toSet()).isEqualTo(ids.toSet())
}
}
@@ -0,0 +1,147 @@
package com.doctate.watch.testfixtures
import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.CaseId
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.OnelinersResponse
import com.doctate.watch.domain.isManual
import java.time.Instant
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Thread-safe in-memory [CaseStore] for tests that need a store but not its
* disk-IO. Mirrors the behaviour of `DiskCaseStore` (sort order, manual-stick
* rule, reconcile window semantics) so contract tests can swap them.
*/
class InMemoryCaseStore : CaseStore {
private data class Entry(
val caseId: String,
val createdAt: Long,
val lastActivityAt: Long,
val syncedToServer: Boolean,
val oneliner: OnelinerState?,
)
private val state = LinkedHashMap<String, Entry>()
private val mutex = Mutex()
private val _flow = MutableStateFlow<List<CaseEntry>>(emptyList())
override val casesFlow: StateFlow<List<CaseEntry>> = _flow.asStateFlow()
override suspend fun createLocal(now: Long): String {
val id = CaseId.new()
mutex.withLock {
state[id] = Entry(id, now, now, syncedToServer = false, oneliner = null)
broadcast()
}
return id
}
override suspend fun markActivity(caseId: String, now: Long) {
mutex.withLock {
val existing = state[caseId]
state[caseId] = existing?.copy(lastActivityAt = now)
?: Entry(caseId, now, now, syncedToServer = false, oneliner = null)
broadcast()
}
}
override suspend fun markSynced(caseId: String) {
mutex.withLock {
val existing = state[caseId] ?: return
if (existing.syncedToServer) return
state[caseId] = existing.copy(syncedToServer = true)
broadcast()
}
}
override suspend fun setOnelinerLocal(caseId: String, state: OnelinerState) {
mutex.withLock {
val existing = this.state[caseId] ?: return
this.state[caseId] = existing.copy(oneliner = state)
broadcast()
}
}
override suspend fun mergeServerSnapshot(response: OnelinersResponse) {
mutex.withLock {
for (entry in response.oneliners) {
val serverActivityMs = (entry.lastRecordingAt ?: entry.updatedAt ?: entry.createdAt)
.toEpochMs()
val existing = state[entry.caseId]
state[entry.caseId] = if (existing != null) {
val keepLocalManual = existing.oneliner.isManual() &&
entry.oneliner !is OnelinerState.Manual
existing.copy(
syncedToServer = true,
oneliner = if (keepLocalManual) existing.oneliner else entry.oneliner,
lastActivityAt = maxOf(existing.lastActivityAt, serverActivityMs),
)
} else {
Entry(
caseId = entry.caseId,
createdAt = entry.createdAt.toEpochMs(),
lastActivityAt = serverActivityMs,
syncedToServer = true,
oneliner = entry.oneliner,
)
}
}
broadcast()
}
}
override suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int {
val asOfMs = response.asOf.toEpochMs()
val windowStartMs = asOfMs - response.windowHours * 3_600_000L
val serverIds = response.oneliners.map { it.caseId }.toHashSet()
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { e ->
e.syncedToServer &&
e.lastActivityAt >= windowStartMs &&
e.caseId !in serverIds
}.map { it.caseId }
for (id in toRemove) {
state.remove(id)
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun cleanupStale(cutoffMs: Long): Int {
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { it.syncedToServer && it.lastActivityAt < cutoffMs }
.map { it.caseId }
for (id in toRemove) {
state.remove(id)
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun clearAll() {
mutex.withLock {
state.clear()
broadcast()
}
}
private fun broadcast() {
_flow.value = state.values
.sortedByDescending { it.lastActivityAt }
.map { CaseEntry(it.caseId, it.createdAt, it.lastActivityAt, it.oneliner, it.syncedToServer) }
}
private fun String.toEpochMs(): Long = Instant.parse(this).toEpochMilli()
}