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 53223bf..e7656da 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 @@ -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 { diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingCleanup.kt b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingCleanup.kt new file mode 100644 index 0000000..f4d83dd --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/audio/PendingCleanup.kt @@ -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 + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/StartupCleanup.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/StartupCleanup.kt new file mode 100644 index 0000000..0137d3d --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/StartupCleanup.kt @@ -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}") + } + } +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingCleanupTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingCleanupTest.kt new file mode 100644 index 0000000..0df8fda --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/audio/PendingCleanupTest.kt @@ -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) + } +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/StartupCleanupTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/StartupCleanupTest.kt new file mode 100644 index 0000000..063f30d --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/StartupCleanupTest.kt @@ -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() + } +}