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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user