feat(watch): startup cleanup for stale markers and orphan audio

Two retention sweeps run once on app launch before the sync worker
spawns. Mirrors doctate-client-core::startup + ::pending_cleanup.

- Marker sweep: synced markers older than 72h are dropped via
  caseStore.cleanupStale; unsynced markers (guarding pending uploads)
  are preserved indefinitely.
- Orphan audio sweep: m4a without sidecar (or vice versa) older than
  24h goes; tmp leftovers go unconditionally. Paired files survive
  regardless of age — the upload worker owns deletion of valid pairs.

Best-effort: failures are logged but never thrown. A hostile filesystem
must not stop the watch from starting.

9 new tests (PendingCleanupTest 6, StartupCleanupTest 3) covering the
window, sibling, and tmp-leftover edges. 52 tests green total.
This commit is contained in:
2026-04-26 23:39:54 +02:00
parent 5e4af12822
commit 11bbeeaefb
5 changed files with 282 additions and 0 deletions
@@ -6,6 +6,7 @@ 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.domain.StartupCleanup
import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.UploadClient
import com.doctate.watch.settings.BuildConfigSettings
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
/**
@@ -65,6 +67,14 @@ class DoctateApp : Application() {
TileService.getUpdater(this).requestUpdate(DoctateTileService::class.java)
}
.launchIn(applicationScope)
// One-shot data-minimization sweep before the sync worker starts.
applicationScope.launch {
StartupCleanup.run(
store = caseStore,
pendingDir = File(filesDir, "recordings/unsynced"),
)
}
}
private companion object {
@@ -0,0 +1,75 @@
package com.doctate.watch.audio
import android.util.Log
import java.io.File
/**
* Reap orphan artefacts in [pendingDir]. Mirrors
* `doctate-client-core::pending_cleanup::cleanup_orphan_audio`.
*
* "Orphan" = an `.m4a` without sidecar (or vice versa) older than
* [ageCutoffMs]. Atomic-write leftovers (`.tmp` files) bypass the cutoff
* — at app startup they cannot be in-progress work; the watch is single-
* process per user.
*
* Returns the count of deleted files. Best-effort: failures are logged
* and skipped, never thrown — a missing-permissions or read-only-fs
* surprise must not stop the watch from starting.
*/
object PendingCleanup {
private const val TAG = "PendingCleanup"
fun cleanupOrphanAudio(pendingDir: File, ageCutoffMs: Long): Int {
if (!pendingDir.exists() || !pendingDir.isDirectory) return 0
val files = pendingDir.listFiles() ?: return 0
val names = files.map { it.name }.toHashSet()
var deleted = 0
for (file in files) {
val name = file.name
// Tmp leftovers: always remove, no age check.
if (name.endsWith(".m4a.tmp") || name.endsWith(".meta.json.tmp")) {
if (file.delete()) {
Log.i(TAG, "tmp leftover removed: $name")
deleted++
} else {
Log.w(TAG, "failed to remove tmp leftover: $name")
}
continue
}
// Orphan m4a: no sidecar present.
if (name.endsWith(".m4a")) {
val stem = name.removeSuffix(".m4a")
val sibling = "$stem.meta.json"
if (sibling in names) continue
if (file.lastModified() < ageCutoffMs) {
if (file.delete()) {
Log.i(TAG, "orphan m4a removed (no sidecar): $name")
deleted++
} else {
Log.w(TAG, "failed to remove orphan m4a: $name")
}
}
continue
}
// Orphan sidecar: no audio present.
if (name.endsWith(".meta.json")) {
val stem = name.removeSuffix(".meta.json")
val sibling = "$stem.m4a"
if (sibling in names) continue
if (file.lastModified() < ageCutoffMs) {
if (file.delete()) {
Log.i(TAG, "orphan sidecar removed (no m4a): $name")
deleted++
} else {
Log.w(TAG, "failed to remove orphan sidecar: $name")
}
}
}
}
return deleted
}
}
@@ -0,0 +1,50 @@
package com.doctate.watch.domain
import android.util.Log
import com.doctate.watch.audio.PendingCleanup
import java.io.File
/**
* One-shot startup cleanup. Mirrors `doctate-client-core::startup`.
*
* Two retention horizons: 72 h for synced markers (non-sensitive UUIDs +
* oneliners), 24 h for orphan audio in the pending queue (sensitive). The
* watch is a "sophisticated microphone" — it must not hoard recordings
* that never reached the server past the dictation-day horizon.
*
* Best-effort: failures are logged, never thrown. A failing sweep
* shouldn't stop the watch from starting.
*/
object StartupCleanup {
private const val TAG = "StartupCleanup"
/** 72 hours — long enough to span a weekend, short enough to limit data exposure. */
const val DEFAULT_MARKER_RETENTION_MS: Long = 72L * 3600L * 1000L
/** 24 hours — orphan recordings older than yesterday are presumed lost. */
const val DEFAULT_ORPHAN_AUDIO_RETENTION_MS: Long = 24L * 3600L * 1000L
suspend fun run(
store: CaseStore,
pendingDir: File,
nowMs: Long = System.currentTimeMillis(),
markerRetentionMs: Long = DEFAULT_MARKER_RETENTION_MS,
orphanAudioRetentionMs: Long = DEFAULT_ORPHAN_AUDIO_RETENTION_MS,
) {
try {
val markerCutoff = nowMs - markerRetentionMs
val n = store.cleanupStale(markerCutoff)
if (n > 0) Log.i(TAG, "startup: stale markers removed: $n")
} catch (e: Exception) {
Log.w(TAG, "startup: marker sweep failed: ${e.message}")
}
try {
val audioCutoff = nowMs - orphanAudioRetentionMs
val n = PendingCleanup.cleanupOrphanAudio(pendingDir, audioCutoff)
if (n > 0) Log.i(TAG, "startup: orphan audio removed: $n")
} catch (e: Exception) {
Log.w(TAG, "startup: orphan-audio sweep failed: ${e.message}")
}
}
}
@@ -0,0 +1,73 @@
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 PendingCleanupTest {
@get:Rule val tempDir = TemporaryFolder()
private fun pendingDir(): File = tempDir.newFolder("unsynced")
private fun touch(parent: File, name: String, mtime: Long): File {
val f = File(parent, name)
f.writeText("x")
f.setLastModified(mtime)
return f
}
@Test fun deletes_orphan_m4a_older_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(1)
assertThat(File(dir, "case-a.m4a").exists()).isFalse()
}
@Test fun preserves_orphan_m4a_younger_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 5_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(0)
assertThat(File(dir, "case-a.m4a").exists()).isTrue()
}
@Test fun preserves_paired_files_regardless_of_age() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 30_000L)
touch(dir, "case-a.meta.json", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(0)
assertThat(File(dir, "case-a.m4a").exists()).isTrue()
assertThat(File(dir, "case-a.meta.json").exists()).isTrue()
}
@Test fun deletes_orphan_sidecar_older_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-x.meta.json", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(1)
assertThat(File(dir, "case-x.meta.json").exists()).isFalse()
}
@Test fun deletes_tmp_leftovers_unconditionally() {
val dir = pendingDir()
val now = 10_000_000L
// Tmp files might be brand new (interrupted write) — still delete.
touch(dir, "case-a.m4a.tmp", mtime = now)
touch(dir, "case-b.meta.json.tmp", mtime = now)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(2)
}
@Test fun returns_zero_for_missing_dir() {
val deleted = PendingCleanup.cleanupOrphanAudio(File(tempDir.root, "missing"), ageCutoffMs = 0L)
assertThat(deleted).isEqualTo(0)
}
}
@@ -0,0 +1,74 @@
package com.doctate.watch.domain
import com.doctate.watch.testfixtures.InMemoryCaseStore
import com.google.common.truth.Truth.assertThat
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class StartupCleanupTest {
@get:Rule val tempDir = TemporaryFolder()
@Test fun sweeps_stale_synced_markers() = runTest {
val store = InMemoryCaseStore()
val now = 10_000_000_000L
// Old + synced → eligible.
val oldId = "case-old"
store.markActivity(oldId, now = now - 100L * 3600L * 1000L) // 100h ago
store.markSynced(oldId)
// Recent + synced → keep.
val recentId = "case-recent"
store.markActivity(recentId, now = now - 1L * 3600L * 1000L) // 1h ago
store.markSynced(recentId)
StartupCleanup.run(
store = store,
pendingDir = tempDir.newFolder("pending"),
nowMs = now,
markerRetentionMs = 72L * 3600L * 1000L,
)
val ids = store.casesFlow.value.map { it.caseId }
assertThat(ids).containsExactly(recentId)
}
@Test fun preserves_pending_markers_regardless_of_age() = runTest {
val store = InMemoryCaseStore()
val now = 10_000_000_000L
val oldPendingId = "case-old-pending"
store.markActivity(oldPendingId, now = now - 200L * 3600L * 1000L)
// No markSynced — pending must be preserved.
StartupCleanup.run(
store = store,
pendingDir = tempDir.newFolder("pending"),
nowMs = now,
markerRetentionMs = 72L * 3600L * 1000L,
)
val ids = store.casesFlow.value.map { it.caseId }
assertThat(ids).containsExactly(oldPendingId)
}
@Test fun reaps_old_orphan_audio_in_pending_dir() = runTest {
val store = InMemoryCaseStore()
val pending = tempDir.newFolder("pending")
val now = 10_000_000_000L
// Old orphan m4a (no sidecar) — should die.
File(pending, "old-orphan.m4a").apply {
writeText("x"); setLastModified(now - 30L * 3600L * 1000L) // 30h ago
}
// Young orphan m4a — should survive (within 24h).
File(pending, "fresh-orphan.m4a").apply {
writeText("x"); setLastModified(now - 1L * 3600L * 1000L) // 1h ago
}
StartupCleanup.run(
store = store,
pendingDir = pending,
nowMs = now,
orphanAudioRetentionMs = 24L * 3600L * 1000L,
)
assertThat(File(pending, "old-orphan.m4a").exists()).isFalse()
assertThat(File(pending, "fresh-orphan.m4a").exists()).isTrue()
}
}