feat(watch): persistent upload queue with sidecar pairs

Recording flow no longer uploads inline. AudioRecorder writes the m4a
straight into filesDir/recordings/unsynced/{caseId}_{utc}.m4a; the VM
follows up with an atomic sidecar (.meta.json) holding case_id + recorded_at.
Sync trigger is a SyncTrigger seam (no-op for now, fleshed out in Phase 4)
so the screen pops back to the case detail immediately after the queue
write — uploads happen out-of-band.

PendingStore mirrors doctate-client-core::upload (sidecar shape, atomic
tmp+rename, scan FIFO sorted by recorded_at, deletePair idempotent).
Audio file naming uses recorded_at_to_filename_stem semantics — colons
swapped for dashes — so paths round-trip with the desktop client and
filesystems that disallow colons.

8 new PendingStoreTest cases (atomic write, FIFO order, orphan skip,
unreadable sidecar resilience, idempotent delete). 43 tests green total.
This commit is contained in:
2026-04-26 23:38:25 +02:00
parent 24644a0144
commit 5e4af12822
7 changed files with 307 additions and 48 deletions
@@ -3,12 +3,15 @@ package com.doctate.watch
import android.app.Application
import android.util.Log
import androidx.wear.tiles.TileService
import com.doctate.watch.audio.PendingStore
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.sync.NoopSyncTrigger
import com.doctate.watch.sync.SyncTrigger
import com.doctate.watch.tile.DoctateTileService
import java.io.File
import kotlinx.coroutines.CoroutineScope
@@ -35,6 +38,14 @@ class DoctateApp : Application() {
DiskCaseStore(File(filesDir, "recordings/cases"))
}
/** Persistent FIFO queue of recordings waiting to be uploaded. */
val pendingStore: PendingStore by lazy {
PendingStore(File(filesDir, "recordings/unsynced"))
}
/** Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */
var syncTrigger: SyncTrigger = NoopSyncTrigger
/** 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 {
@@ -11,14 +11,20 @@ import java.io.File
*
* Format: 16 kHz mono AAC-LC at 64 kbps — matches Whisper's native sample rate
* so the server-side remux can skip a resample stage.
*
* Writes directly into the caller-supplied target file so the pending
* upload queue can sit on the audio without an intermediate copy. The
* caller is responsible for choosing a path that survives crashes
* (typically [PendingStore.audioPathFor]).
*/
class AudioRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var currentFile: File? = null
fun start(): File {
/** Start recording into [outFile]. The parent directory is created if missing. */
fun start(outFile: File): File {
require(recorder == null) { "recorder already active" }
val outFile = File(context.cacheDir, "rec-${System.currentTimeMillis()}.m4a")
outFile.parentFile?.takeIf { !it.exists() }?.mkdirs()
val r = newRecorderInstance().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
@@ -0,0 +1,103 @@
package com.doctate.watch.audio
import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import org.json.JSONObject
/**
* Persistent FIFO queue of recordings waiting to be uploaded. One pair per
* recording: `{stem}.m4a` + `{stem}.meta.json`. The audio is written by
* [AudioRecorder] directly into [dir]; the sidecar is written here, atomic
* tmp+rename so a crash mid-write cannot leave a partial file.
*
* Mirrors `doctate-client-core::upload` exactly (sidecar shape, scan rules,
* orphan handling). Wire-compatible with the desktop client.
*/
class PendingStore(private val dir: File) {
/** Compute the target audio path for a new recording. Caller passes it to [AudioRecorder.start]. */
fun audioPathFor(caseId: String, recordedAtUtc: String): File {
if (!dir.exists()) dir.mkdirs()
val stem = "${caseId}_${recordedAtToFilenameStem(recordedAtUtc)}"
return File(dir, "$stem.m4a")
}
/** Sidecar path next to a `.m4a` — same dir, same stem, `.meta.json`. */
fun sidecarPathFor(audio: File): File =
File(audio.parentFile ?: dir, "${audio.nameWithoutExtension}.meta.json")
/**
* Persist a sidecar atomically. Caller must have already written the
* `.m4a` audio to [PendingUpload.file]; the sidecar makes the pair
* complete and visible to [scan].
*/
fun writeSidecar(upload: PendingUpload) {
if (!dir.exists()) dir.mkdirs()
val sidecar = sidecarPathFor(upload.file)
val tmp = File(sidecar.parentFile ?: dir, "${sidecar.name}.tmp")
val json = JSONObject().apply {
put("case_id", upload.caseId)
put("recorded_at", upload.recordedAt)
}.toString(2)
tmp.writeText(json, Charsets.UTF_8)
Files.move(
tmp.toPath(),
sidecar.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
/**
* Scan [dir] for `.m4a` files with a matching sidecar. Returns the
* complete pairs sorted by `recordedAt` ascending — the upload worker
* processes them FIFO so the oldest dictation wins. Orphans (m4a
* without sidecar, unreadable sidecar) are logged and skipped — one
* broken pair must not stop recovery of the rest.
*/
fun scan(): List<PendingUpload> {
if (!dir.exists()) return emptyList()
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".m4a") } ?: return emptyList()
val out = mutableListOf<PendingUpload>()
for (audio in files) {
val sidecar = sidecarPathFor(audio)
if (!sidecar.exists()) continue
try {
val obj = JSONObject(sidecar.readText(Charsets.UTF_8))
out += PendingUpload(
caseId = obj.getString("case_id"),
recordedAt = obj.getString("recorded_at"),
file = audio,
)
} catch (e: Exception) {
Log.w(TAG, "skipping unreadable sidecar ${sidecar.name}: ${e.message}")
}
}
return out.sortedBy { it.recordedAt }
}
/**
* Drop both files of a pair. Idempotent — missing files are not an
* error. Called by the sync worker after a Success or Terminal ACK.
*/
fun deletePair(audio: File) {
val sidecar = sidecarPathFor(audio)
if (audio.exists() && !audio.delete()) {
Log.w(TAG, "failed to delete audio ${audio.name}")
}
if (sidecar.exists() && !sidecar.delete()) {
Log.w(TAG, "failed to delete sidecar ${sidecar.name}")
}
}
companion object {
private const val TAG = "PendingStore"
/** RFC3339 → filesystem-safe stem (replace `:` with `-`). 1:1 to
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
fun recordedAtToFilenameStem(recordedAt: String): String =
recordedAt.replace(':', '-')
}
}
@@ -0,0 +1,17 @@
package com.doctate.watch.audio
import java.io.File
/**
* One recording waiting to be uploaded. Mirrors
* `doctate-client-core::PendingUpload` (Rust).
*
* The [file] path is not persisted in the sidecar JSON — it's recovered
* from the sidecar's on-disk location, so the pair stays valid if the
* pending directory is moved.
*/
data class PendingUpload(
val caseId: String,
val recordedAt: String,
val file: File,
)
@@ -1,5 +1,6 @@
package com.doctate.watch.presentation
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -8,10 +9,13 @@ 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.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.net.UploadClient
import com.doctate.watch.net.UploadResult
import com.doctate.watch.sync.SyncKick
import com.doctate.watch.sync.SyncTrigger
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -22,17 +26,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
class RecordingViewModel(
private val caseId: String,
private val recorder: AudioRecorder,
private val uploadClient: UploadClient,
private val pendingStore: PendingStore,
private val caseStore: CaseStore,
private val syncTrigger: SyncTrigger,
private val appContext: Context,
private val applicationScope: CoroutineScope,
) : ViewModel() {
@@ -45,6 +46,10 @@ class RecordingViewModel(
// Guards against double-finalization when stop, discard and auto-stop race.
private val finalizationStarted = AtomicBoolean(false)
// Captured once recording starts; reused by finalizeAndUpload so audio file name
// and sidecar metadata agree on the same `recorded_at` timestamp.
private var pendingRecordedAt: String? = null
fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap)
fun onPermissionResult(granted: Boolean) {
@@ -93,8 +98,11 @@ class RecordingViewModel(
private fun startRecordingFlow() {
finalizationStarted.set(false)
recordingJob = viewModelScope.launch {
val recordedAt = Instant.now().toString()
val target = pendingStore.audioPathFor(caseId, recordedAt)
try {
withContext(Dispatchers.IO) { recorder.start() }
withContext(Dispatchers.IO) { recorder.start(target) }
pendingRecordedAt = recordedAt
} catch (e: Exception) {
dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false))
return@launch
@@ -117,6 +125,13 @@ class RecordingViewModel(
}
}
/**
* Hand the freshly-finished recording over to the sync queue and kick
* the worker. The actual `POST /api/upload` happens in the foreground
* service (Phase 4); from the UI's point of view "queued" looks like
* "uploaded" — we surface the success state immediately and pop back
* to the case detail.
*/
private suspend fun finalizeAndUpload() {
val finalFile = try {
withContext(Dispatchers.IO) { recorder.stop() }
@@ -125,42 +140,19 @@ class RecordingViewModel(
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
return
}
val recordedAt = Instant.now().toString()
val recordedAt = pendingRecordedAt ?: Instant.now().toString()
pendingRecordedAt = null
try {
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
is UploadResult.Success -> {
caseStore.markActivity(caseId)
caseStore.markSynced(caseId)
triggerFakeOnelinerBurst(caseId)
dispatch(RecordingEvent.UploadSucceeded(caseId))
}
is UploadResult.Transient -> {
Log.w(TAG, "upload transient fail: ${result.reason}")
dispatch(RecordingEvent.UploadFailed(result.reason, transient = true))
}
is UploadResult.Terminal -> {
Log.w(TAG, "upload terminal fail: ${result.reason}")
dispatch(RecordingEvent.UploadFailed(result.reason, transient = false))
}
withContext(Dispatchers.IO) {
pendingStore.writeSidecar(PendingUpload(caseId, recordedAt, finalFile))
}
} finally {
finalFile.delete()
}
}
/**
* PoC stand-in for post-stop-burst polling. Real flow would poll
* `/api/oneliners` with a 2s/60s budget; here we just set a placeholder
* so the UI changes visibly once the "oneliner arrives".
*/
private fun triggerFakeOnelinerBurst(caseId: String) {
applicationScope.launch {
delay(2_000)
val label = fakeLabelFormat.format(Date())
caseStore.setOnelinerLocal(
caseId,
OnelinerState.Ready("[PoC] OneLiner $label", Instant.now().toString()),
)
caseStore.markActivity(caseId)
syncTrigger.kick(appContext, SyncKick.NewPending)
dispatch(RecordingEvent.UploadSucceeded(caseId))
} catch (e: Exception) {
Log.w(TAG, "writeSidecar failed: ${e.message}")
// Audio is on disk but no sidecar: pending_cleanup will reap the orphan.
dispatch(RecordingEvent.UploadFailed("Queue write failed: ${e.message}", transient = false))
}
}
@@ -171,7 +163,6 @@ class RecordingViewModel(
companion object {
private const val TAG = "RecordingVM"
private const val MAX_SECONDS = 300
private val fakeLabelFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
fun factoryFor(caseId: String): ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -179,8 +170,10 @@ class RecordingViewModel(
RecordingViewModel(
caseId = caseId,
recorder = AudioRecorder(app),
uploadClient = app.uploadClient,
pendingStore = app.pendingStore,
caseStore = app.caseStore,
syncTrigger = app.syncTrigger,
appContext = app,
applicationScope = app.applicationScope,
)
}
@@ -0,0 +1,26 @@
package com.doctate.watch.sync
import android.content.Context
/**
* Kick the sync worker. Implementation lives in Phase 4 (`SyncServiceLauncher`);
* Phase 2 only needs the seam so the recording flow can call into it.
*
* Different kicks carry different intent — `NewPending` says the queue
* just grew, `BurstPoll` (Phase 7) says "poll aggressively for this case
* for the next 60 seconds".
*/
sealed interface SyncKick {
data object NewPending : SyncKick
data class BurstPoll(val caseId: String, val deadlineMs: Long) : SyncKick
}
/** Indirection so the ViewModel can call into the sync service without owning a reference to it. */
fun interface SyncTrigger {
fun kick(context: Context, kick: SyncKick)
}
/** No-op. Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */
object NoopSyncTrigger : SyncTrigger {
override fun kick(context: Context, kick: SyncKick) = Unit
}
@@ -0,0 +1,103 @@
package com.doctate.watch.audio
import com.google.common.truth.Truth.assertThat
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PendingStoreTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newStore(): PendingStore = PendingStore(File(tempDir.root, "unsynced"))
private fun writeAudio(store: PendingStore, caseId: String, recordedAt: String): File {
val audio = store.audioPathFor(caseId, recordedAt)
audio.writeBytes(byteArrayOf(0x66, 0x74, 0x79, 0x70)) // dummy 'ftyp' bytes
return audio
}
@Test fun audioPathFor_replaces_colons_in_recorded_at() {
val store = newStore()
val path = store.audioPathFor("case-aaa", "2026-04-26T10:30:45Z")
assertThat(path.name).isEqualTo("case-aaa_2026-04-26T10-30-45Z.m4a")
}
@Test fun writeSidecar_atomic_no_tmp_left() {
val store = newStore()
val audio = writeAudio(store, "case-a", "2026-04-26T10:30:45Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:30:45Z", audio))
val sidecar = store.sidecarPathFor(audio)
assertThat(sidecar.exists()).isTrue()
val tmpArtefacts = audio.parentFile?.listFiles { f -> f.name.endsWith(".tmp") } ?: emptyArray()
assertThat(tmpArtefacts).isEmpty()
}
@Test fun scan_returns_pairs_only_when_sidecar_present() {
val store = newStore()
// pair 1: complete
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
// pair 2: orphan audio (no sidecar) — must be skipped, not crash the scan
writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
val found = store.scan()
assertThat(found.map { it.caseId }).containsExactly("case-a")
}
@Test fun scan_skips_unreadable_sidecar_without_killing_recovery() {
val store = newStore()
// pair 1: complete
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
// pair 2: corrupted sidecar
val b = writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
store.sidecarPathFor(b).writeText("{ this is not valid json", Charsets.UTF_8)
val found = store.scan()
assertThat(found.map { it.caseId }).containsExactly("case-a")
}
@Test fun scan_returns_pairs_in_recordedAt_ascending_order_for_FIFO_upload() {
val store = newStore()
val ts3 = "2026-04-26T12:00:00Z"
val ts1 = "2026-04-26T10:00:00Z"
val ts2 = "2026-04-26T11:00:00Z"
// Insert out of chronological order — scan must still sort.
for ((id, ts) in listOf("c-c" to ts3, "c-a" to ts1, "c-b" to ts2)) {
val audio = writeAudio(store, id, ts)
store.writeSidecar(PendingUpload(id, ts, audio))
}
val recorded = store.scan().map { it.recordedAt }
assertThat(recorded).isInOrder() // ascending
}
@Test fun scan_returns_empty_for_missing_dir() {
val missing = PendingStore(File(tempDir.root, "does-not-exist"))
assertThat(missing.scan()).isEmpty()
}
@Test fun deletePair_idempotent_even_when_files_missing() {
val store = newStore()
val audio = writeAudio(store, "case-x", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-x", "2026-04-26T10:00:00Z", audio))
// First delete: both files go.
store.deletePair(audio)
assertThat(audio.exists()).isFalse()
assertThat(store.sidecarPathFor(audio).exists()).isFalse()
// Second delete: no exception.
store.deletePair(audio)
}
@Test fun roundtrip_via_scan_recovers_caseId_and_recordedAt() {
val store = newStore()
val audio = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", audio))
val found = store.scan().single()
assertThat(found.caseId).isEqualTo("case-a")
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
}
}