From 5e4af1282299fa0b09fb06c6f149b130f9298a67 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 26 Apr 2026 23:38:25 +0200 Subject: [PATCH] feat(watch): persistent upload queue with sidecar pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/java/com/doctate/watch/DoctateApp.kt | 11 ++ .../com/doctate/watch/audio/AudioRecorder.kt | 10 +- .../com/doctate/watch/audio/PendingStore.kt | 103 ++++++++++++++++++ .../com/doctate/watch/audio/PendingUpload.kt | 17 +++ .../watch/presentation/RecordingViewModel.kt | 85 +++++++-------- .../com/doctate/watch/sync/SyncTrigger.kt | 26 +++++ .../doctate/watch/audio/PendingStoreTest.kt | 103 ++++++++++++++++++ 7 files changed, 307 insertions(+), 48 deletions(-) create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingStore.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingUpload.kt create mode 100644 watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncTrigger.kt create mode 100644 watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingStoreTest.kt 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 3707558..53223bf 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 @@ -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 { diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt b/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt index 6fac0a1..dff3292 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/audio/AudioRecorder.kt @@ -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) diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingStore.kt b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingStore.kt new file mode 100644 index 0000000..6cf8684 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingStore.kt @@ -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 { + if (!dir.exists()) return emptyList() + val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".m4a") } ?: return emptyList() + val out = mutableListOf() + 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(':', '-') + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingUpload.kt b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingUpload.kt new file mode 100644 index 0000000..f7d177d --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingUpload.kt @@ -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, +) 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 9fde746..e144ee5 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 @@ -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, ) } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncTrigger.kt b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncTrigger.kt new file mode 100644 index 0000000..15b9deb --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncTrigger.kt @@ -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 +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingStoreTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingStoreTest.kt new file mode 100644 index 0000000..8becbbb --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingStoreTest.kt @@ -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) + } +}