94361110a63a

feat: Add vault duplicate cleanup (v2.3.20)

- Mirror browser duplicate scan and remove-to-trash behavior - Keep newest duplicate and preserve decoy-mode guard - Verify repository tests and full Gradle gate

time
2026-06-26 07:55 UTC · 4 months ago
machine
koh2
parent
a175832dabe6
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+45 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+36 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+62 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+28 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23190020-        versionName "2.3.19"19+        versionCode 23200020+        versionName "2.3.20"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+45 −0
@@ -372,9 +372,54 @@372372     val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")373373     val current = s.trashRetentionDays ?: 0374374     val isPro by vm.isPro.collectAsState()375+    val dedupCount by vm.dedupCount.collectAsState()376+    val dedupRunning by vm.dedupRunning.collectAsState()377+    val dedupDone by vm.dedupDone.collectAsState()375378 376379     SubLabel("ENTRIES", fg, top = 0.dp)377380     RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }381+    RowLine(fg) {382+        Name("Duplicates", fg)383+        val done = dedupDone384+        if (done != null) {385+            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)386+        } else {387+            val count = dedupCount388+            val label = when {389+                dedupRunning -> "scanning..."390+                count == null -> "scan"391+                count == 0 -> "none found"392+                else -> "$count found"393+            }394+            SubtleButton(label, fg) {395+                if (count == null || count == 0) vm.scanDuplicates()396+            }397+        }398+    }399+    if ((dedupCount ?: 0) > 0 && dedupDone == null) {400+        Column(401+            Modifier.fillMaxWidth()402+                .padding(top = 2.dp, bottom = 8.dp)403+                .clip(RoundedCornerShape(8.dp))404+                .background(fg.copy(alpha = 0.08f))405+                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))406+                .padding(10.dp),407+            verticalArrangement = Arrangement.spacedBy(8.dp),408+        ) {409+            val count = dedupCount ?: 0410+            Text(411+                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",412+                color = fg.copy(alpha = 0.82f),413+                fontFamily = PeachType.Body,414+                fontSize = 11.sp,415+                lineHeight = 16.sp,416+            )417+            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {418+                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }419+                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }420+            }421+        }422+    }378423 379424     SubLabel("MAINTENANCE", fg)380425     RowLine(fg) {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+36 −0
@@ -53,6 +53,15 @@5353     private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)5454     val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()5555 56+    private val _dedupCount = MutableStateFlow<Int?>(null)57+    val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()58+59+    private val _dedupRunning = MutableStateFlow(false)60+    val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()61+62+    private val _dedupDone = MutableStateFlow<Int?>(null)63+    val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()64+5665     fun sendFeedback(type: String, message: String) {5766         if (_feedbackState.value is FeedbackState.Sending) return5867         _feedbackState.value = FeedbackState.Sending@@ -72,6 +81,33 @@7281     fun resetFeedback() {7382         _feedbackState.value = FeedbackState.Idle7483     }84+85+    fun scanDuplicates() {86+        if (_dedupRunning.value) return87+        _dedupRunning.value = true88+        _dedupDone.value = null89+        viewModelScope.launch {90+            _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)91+            _dedupRunning.value = false92+        }93+    }94+95+    fun cancelDuplicateRemoval() {96+        if (_dedupRunning.value) return97+        _dedupCount.value = null98+    }99+100+    fun removeDuplicates() {101+        if (_dedupRunning.value) return102+        _dedupRunning.value = true103+        viewModelScope.launch {104+            val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)105+            _dedupDone.value = removed106+            _dedupCount.value = 0107+            _dedupRunning.value = false108+        }109+    }110+75111     val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries76112     val settings: StateFlow<VaultSettings> = vault.settings77113 

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+62 −0
@@ -551,6 +551,41 @@551551         removed552552     }553553 554+    /** Browser parity: number of active entries that dedupe would move to trash. */555+    suspend fun countDuplicateEntries(): Int = mutex.withLock {556+        requireUnlocked()557+        countDuplicateEntriesLocked()558+    }559+560+    /**561+     * Browser parity: soft-delete duplicate entries, keeping the most recently562+     * modified member of each group. Refuse in decoy mode so the duress vault is563+     * not reshaped into a forensically distinct state.564+     */565+    suspend fun deduplicateEntries(): Int = mutex.withLock {566+        requireUnlocked()567+        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")568+        val active = entryList.filter { it.deletedAt == null }569+        val keepIds = mutableSetOf<String>()570+        active.groupBy { it.duplicateKey() }.values.forEach { group ->571+            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id572+        }573+574+        val rollback = entryList.toMutableList()575+        var removed = 0576+        entryList = entryList.map { entry ->577+            if (entry.deletedAt == null && entry.id !in keepIds) {578+                removed++579+                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)580+                entry.copy(deletedAt = stamp, modifiedAt = stamp)581+            } else {582+                entry583+            }584+        }.toMutableList()585+        if (removed > 0) persistOrRollback(rollback)586+        removed587+    }588+554589     fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }555590     fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }556591     fun getDeletedEntries(): List<VaultEntry> =@@ -899,6 +934,33 @@899934 900935     private fun requireUnlocked() {901936         if (derivedKey == null) throw IllegalStateException("vault_locked")937+    }938+939+    private fun countDuplicateEntriesLocked(): Int {940+        val seen = mutableMapOf<String, VaultEntry>()941+        var duplicates = 0942+        for (entry in entryList.filter { it.deletedAt == null }) {943+            val key = entry.duplicateKey()944+            val existing = seen[key]945+            if (existing == null) {946+                seen[key] = entry947+            } else {948+                duplicates++949+                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt950+                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt951+                if (entryStamp > existingStamp) seen[key] = entry952+            }953+        }954+        return duplicates955+    }956+957+    private fun VaultEntry.duplicateKey(): String {958+        if (type == "note") return "note|${title.lowercase()}"959+        val passkeyData = passkey960+        if (passkeyData != null) {961+            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"962+        }963+        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"902964     }903965 904966     /**

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+28 −0
@@ -33,6 +33,9 @@3333     private fun login(title: String, password: String = "pw") =3434         VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)3535 36+    private fun note(title: String) =37+        VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)38+3639     @Test fun create_persists_and_unlocks() = runBlocking {3740         val storage = FakeStorage()3841         val repo = VaultRepository(storage)@@ -81,6 +84,31 @@8184         repo.deleteEntry(e.id)8285         repo.purgeEntry(e.id)8386         assertEquals(0, repo.getTombstones().size)87+    }88+89+    @Test fun duplicate_count_matches_browser_keys() = runBlocking {90+        val repo = VaultRepository(FakeStorage())91+        repo.createVault("pw")92+        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))93+        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))94+        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))95+        repo.addEntry(note("Ideas"))96+        repo.addEntry(note("ideas"))97+98+        assertEquals(2, repo.countDuplicateEntries())99+    }100+101+    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {102+        val repo = VaultRepository(FakeStorage())103+        repo.createVault("pw")104+        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))105+        Thread.sleep(2)106+        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))107+108+        assertEquals(1, repo.deduplicateEntries())109+110+        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })111+        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })84112     }85113 86114     @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {

view file