94361110a63a
feat: Add vault duplicate cleanup (v2.3.20)
- time
- 2026-06-26 07:55 UTC · 4 months ago
- machine
- koh2
- parent
- a175832dabe6
↓ peach-android-94361110.tar.gzatlas at this save
files changed5 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 −0app/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 {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) {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 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 /**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 {