From 24644a0144fa92eac2861c8d9b5bea25ec1a8868 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 26 Apr 2026 23:35:00 +0200 Subject: [PATCH] feat(watch): replace CaseStoreStub with disk-backed CaseStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- watch/wearos/app/build.gradle.kts | 8 + .../main/java/com/doctate/watch/DoctateApp.kt | 34 ++- .../com/doctate/watch/domain/CaseMarker.kt | 85 +++++++ .../com/doctate/watch/domain/CaseStore.kt | 55 ++++ .../com/doctate/watch/domain/CaseStoreStub.kt | 122 --------- .../com/doctate/watch/domain/DiskCaseStore.kt | 239 ++++++++++++++++++ .../com/doctate/watch/domain/OnelinersWire.kt | 24 ++ .../com/doctate/watch/presentation/AppNav.kt | 15 +- .../watch/presentation/CaseDetailScreen.kt | 19 +- .../watch/presentation/CaseListScreen.kt | 6 +- .../watch/presentation/RecordingViewModel.kt | 9 +- .../doctate/watch/tile/DoctateTileService.kt | 13 +- .../doctate/watch/domain/CaseStoreStubTest.kt | 87 ------- .../doctate/watch/domain/DiskCaseStoreTest.kt | 209 +++++++++++++++ .../watch/testfixtures/InMemoryCaseStore.kt | 147 +++++++++++ 15 files changed, 840 insertions(+), 232 deletions(-) create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseMarker.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStore.kt delete mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/DiskCaseStore.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersWire.kt delete mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt create mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/domain/DiskCaseStoreTest.kt create mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/testfixtures/InMemoryCaseStore.kt diff --git a/watch/wearos/app/build.gradle.kts b/watch/wearos/app/build.gradle.kts index da740b6..258e5e6 100644 --- a/watch/wearos/app/build.gradle.kts +++ b/watch/wearos/app/build.gradle.kts @@ -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) diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt index c494147..3707558 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt @@ -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" } } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseMarker.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseMarker.kt new file mode 100644 index 0000000..a0e8f9f --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseMarker.kt @@ -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") + } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStore.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStore.kt new file mode 100644 index 0000000..e3e4d89 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStore.kt @@ -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> + + /** 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() +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt deleted file mode 100644 index 9caceb4..0000000 --- a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt +++ /dev/null @@ -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>(emptyList()) - val cases: StateFlow> = _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 - } -} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/DiskCaseStore.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/DiskCaseStore.kt new file mode 100644 index 0000000..5f7dae8 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/DiskCaseStore.kt @@ -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() + private val mutex = Mutex() + private val _flow = MutableStateFlow>(emptyList()) + + override val casesFlow: StateFlow> = _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() + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersWire.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersWire.kt new file mode 100644 index 0000000..43a5200 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersWire.kt @@ -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, +) + +data class OnelinerEntry( + val caseId: String, + val oneliner: OnelinerState?, + val createdAt: String, + val lastRecordingAt: String?, + val updatedAt: String?, +) diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt index 171b646..09a8113 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt @@ -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) { 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) { 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) { navController.navigate(Routes.detail(caseId)) }, onNewCase = { - val id = CaseStoreStub.createLocal() - navController.navigate(Routes.recording(id)) + coroutineScope.launch { + val id = app.caseStore.createLocal() + navController.navigate(Routes.recording(id)) + } }, ) } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt index a678d81..e88b1bb 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt @@ -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,10 +74,12 @@ fun CaseDetailScreen( ?.trim() .orEmpty() if (text.isNotEmpty()) { - CaseStoreStub.setOneliner( - caseId, - OnelinerState.Manual(text, setAt = Instant.now().toString()), - ) + coroutineScope.launch { + app.caseStore.setOnelinerLocal( + caseId, + OnelinerState.Manual(text, setAt = Instant.now().toString()), + ) + } } } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt index 3294f9f..8d13e76 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt @@ -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. diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt index ebb2e15..9fde746 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt @@ -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, ) } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt b/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt index aa74d5e..0f487f8 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt @@ -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 { - val current = CaseStoreStub.currentCase + val app = applicationContext as DoctateApp + val current = app.caseStore.currentCase val layout = LayoutElementBuilders.Layout.Builder() .setRoot(buildTileLayout(packageName, current)) .build() diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt deleted file mode 100644 index a522d03..0000000 --- a/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt +++ /dev/null @@ -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() -} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/DiskCaseStoreTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/DiskCaseStoreTest.kt new file mode 100644 index 0000000..e87eda3 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/DiskCaseStoreTest.kt @@ -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()) + } +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/testfixtures/InMemoryCaseStore.kt b/watch/wearos/app/src/test/java/com/doctate/watch/testfixtures/InMemoryCaseStore.kt new file mode 100644 index 0000000..3db8fb1 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/testfixtures/InMemoryCaseStore.kt @@ -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() + private val mutex = Mutex() + private val _flow = MutableStateFlow>(emptyList()) + + override val casesFlow: StateFlow> = _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() +}