827354a8a29d
feat: Add vault history restore (v2.3.21)
- time
- 2026-06-26 08:01 UTC · 4 months ago
- machine
- koh2
- parent
- 94361110a63a
↓ peach-android-827354a8.tar.gzatlas at this save
files changed6 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+79 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+22 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+54 −2app/src/main/java/com/peachpasswords/vault/VaultStore.kt+25 −1app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+36 −1app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23200020- versionName "2.3.20"19+ versionCode 23210020+ versionName "2.3.21"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+79 −0
@@ -56,7 +56,11 @@5656 import com.peachpasswords.ui.theme.PeachType5757 import androidx.hilt.navigation.compose.hiltViewModel5858 import com.peachpasswords.vault.VaultSettings59+import com.peachpasswords.vault.VaultSnapshot5960 import kotlinx.coroutines.launch61+import java.text.SimpleDateFormat62+import java.util.Date63+import java.util.Locale6064 6165 private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"6266 @@ -375,6 +379,8 @@375379 val dedupCount by vm.dedupCount.collectAsState()376380 val dedupRunning by vm.dedupRunning.collectAsState()377381 val dedupDone by vm.dedupDone.collectAsState()382+ val history by vm.vaultHistory.collectAsState()383+ val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()378384 379385 SubLabel("ENTRIES", fg, top = 0.dp)380386 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }@@ -441,6 +447,52 @@441447 }442448 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }443449 450+ var historyShowAll by remember { mutableStateOf(false) }451+ SubLabel("VAULT HISTORY", fg)452+ if (history.isEmpty()) {453+ Text(454+ "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",455+ color = fg.copy(alpha = 0.68f),456+ fontFamily = PeachType.Body,457+ fontSize = 11.sp,458+ lineHeight = 16.sp,459+ modifier = Modifier.padding(bottom = 6.dp),460+ )461+ } else {462+ Text(463+ "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",464+ color = fg.copy(alpha = 0.68f),465+ fontFamily = PeachType.Body,466+ fontSize = 11.sp,467+ lineHeight = 16.sp,468+ modifier = Modifier.padding(bottom = 4.dp),469+ )470+ history.take(if (historyShowAll) history.size else 3).forEach { snap ->471+ RowLine(fg) {472+ Column(Modifier.weight(1f)) {473+ Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)474+ Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)475+ }476+ SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {477+ vm.restoreVaultSnapshot(snap.id)478+ }479+ }480+ }481+ if (history.size > 3) {482+ RowLine(fg) {483+ Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)484+ SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }485+ }486+ }487+ Text(488+ "${history.size} of 5 snapshots",489+ color = fg.copy(alpha = 0.58f),490+ fontFamily = PeachType.Mono,491+ fontSize = 10.sp,492+ modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),493+ )494+ }495+444496 var advancedOpen by remember { mutableStateOf(false) }445497 SubLabel("ADVANCED", fg)446498 RowLine(fg) {@@ -457,6 +509,33 @@457509 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }458510 }459511 }512+}513+514+private fun snapshotSubLabel(snapshot: VaultSnapshot): String {515+ val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }516+ return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")517+}518+519+private fun snapshotReasonLabel(reason: String): String = when (reason) {520+ "local_save" -> "saved"521+ "sync_received" -> "synced"522+ "import" -> "imported"523+ "restore" -> "restored"524+ else -> reason525+}526+527+private fun snapshotTimeLabel(savedAt: Long): String {528+ val diff = System.currentTimeMillis() - savedAt529+ if (diff < 60_000) return "Just now"530+ if (diff < 3_600_000) return "${diff / 60_000}m ago"531+ val date = Date(savedAt)532+ val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())533+ val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)534+ val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)535+ if (today == thenDay) return "Today $time"536+ val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))537+ if (yesterday == thenDay) return "Yesterday $time"538+ return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)460539 }461540 462541 /* ──────────────────────────────── Sync ────────────────────────────────── */app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+22 −0
@@ -12,6 +12,7 @@1212 import com.peachpasswords.ui.lock.Biometrics1313 import com.peachpasswords.vault.VaultCrypto1414 import com.peachpasswords.vault.VaultRepository15+import com.peachpasswords.vault.VaultSnapshot1516 import com.peachpasswords.vault.VaultSettings1617 import dagger.hilt.android.lifecycle.HiltViewModel1718 import dagger.hilt.android.qualifiers.ApplicationContext@@ -62,6 +63,16 @@6263 private val _dedupDone = MutableStateFlow<Int?>(null)6364 val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()6465 66+ private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())67+ val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()68+69+ private val _restoringSnapshotId = MutableStateFlow<String?>(null)70+ val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()71+72+ fun refreshVaultHistory() {73+ _vaultHistory.value = vault.getVaultHistory()74+ }75+6576 fun sendFeedback(type: String, message: String) {6677 if (_feedbackState.value is FeedbackState.Sending) return6778 _feedbackState.value = FeedbackState.Sending@@ -104,7 +115,18 @@104115 val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)105116 _dedupDone.value = removed106117 _dedupCount.value = 0118+ refreshVaultHistory()107119 _dedupRunning.value = false120+ }121+ }122+123+ fun restoreVaultSnapshot(snapshotId: String) {124+ if (_restoringSnapshotId.value != null) return125+ _restoringSnapshotId.value = snapshotId126+ viewModelScope.launch {127+ runCatching { vault.restoreVaultSnapshot(snapshotId) }128+ _restoringSnapshotId.value = null129+ refreshVaultHistory()108130 }109131 }110132 app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+54 −2
@@ -17,6 +17,7 @@1717 1818 /** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */1919 private const val PASSWORD_HISTORY_MAX = 5020+private const val VAULT_HISTORY_MAX = 52021 2122 sealed interface UnlockResult {2223 data object Success : UnlockResult@@ -638,7 +639,19 @@638639 fun getTombstones(): List<Tombstone> =639640 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }640641 fun getVaultVersion(): Long = store.version()642+ fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots641643 644+ suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {645+ val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false646+ val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false647+ takeVaultSnapshot("restore")648+ if (!store.writeSlotA(restoredA)) return false649+ if (!store.writeSlotB(snapshot.vault_b)) return false650+ store.stampLocalMutation()651+ lock()652+ true653+ }654+642655 // ---- Sync apply (does NOT advance the version) ------------------------643656 644657 /**@@ -676,7 +689,7 @@676689 }677690 678691 if (applied > 0) {679- persistSlotA(bumpVersion = false)692+ persistSlotA(bumpVersion = false, snapshotReason = "sync_received")680693 publish()681694 }682695 applied@@ -963,6 +976,44 @@963976 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"964977 }965978 979+ private fun takeVaultSnapshot(reason: String) {980+ runCatching {981+ val slotA = store.loadSlotA() ?: return982+ val vaultA = VaultCodec.encodeSlot(slotA)983+ val vaultB = store.loadSlotB() ?: slotA.blob984+ val history = store.loadVaultHistory()985+ val latest = history.snapshots.firstOrNull()986+ if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return987+ val entryCount = if (derivedKey != null && !decoyMode) {988+ bucketEntryCount(entryList.count { it.deletedAt == null })989+ } else {990+ null991+ }992+ val snapshot = VaultSnapshot(993+ id = UUID.randomUUID().toString(),994+ savedAt = System.currentTimeMillis(),995+ reason = reason,996+ vault_a = vaultA,997+ vault_b = vaultB,998+ entryCount = entryCount,999+ )1000+ store.writeVaultHistory(1001+ history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1002+ )1003+ }1004+ }1005+1006+ private fun bucketEntryCount(n: Int): Int = when {1007+ n <= 0 -> 01008+ n <= 10 -> 101009+ n <= 25 -> 251010+ n <= 50 -> 501011+ n <= 100 -> 1001012+ n <= 250 -> 2501013+ n <= 500 -> 5001014+ else -> ((n + 499) / 500) * 5001015+ }1016+9661017 /**9671018 * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),9681019 * so it is stored explicitly and survives the sync wire format rather than being@@ -1033,7 +1084,7 @@10331084 }10341085 10351086 /** Re-seal slot A from the current in-memory state under the live key. */1036- private fun persistSlotA(bumpVersion: Boolean): Boolean {1087+ private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {10371088 val key = derivedKey ?: throw IllegalStateException("vault_locked")10381089 // Decoy sessions persist to the decoy slot only — never touch the real slot A.10391090 if (decoyMode) {@@ -1043,6 +1094,7 @@10431094 return store.writeDecoySlot(prior.copy(blob = blob))10441095 }10451096 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1097+ if (snapshotReason != null) takeVaultSnapshot(snapshotReason)10461098 val plaintext = VaultCodec.encode(currentPayload())10471099 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))10481100 val next = prior.copy(blob = blob)app/src/main/java/com/peachpasswords/vault/VaultStore.kt+25 −1
@@ -61,6 +61,11 @@6161 val blob: String,6262 )6363 64+@Serializable65+data class VaultHistory(66+ val snapshots: List<VaultSnapshot> = emptyList(),67+)68+6469 /** A soft-deleted entry, exposed to the sync layer for last-write-wins merges. */6570 @Serializable6671 data class Tombstone(val id: String, val deletedAt: Long)@@ -107,6 +112,8 @@107112 fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()108113 fun encodeSlot(s: StoredVault): String = json.encodeToString(s)109114 fun decodeSlot(s: String): StoredVault? = runCatching { json.decodeFromString<StoredVault>(s) }.getOrNull()115+ fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)116+ fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()110117 }111118 112119 /**@@ -118,6 +125,7 @@118125 fun loadSlotA(): StoredVault?119126 fun hasVault(): Boolean120127 fun writeSlotA(slot: StoredVault): Boolean128+ fun loadSlotB(): String? = null121129 fun writeSlotB(blobBase64: String): Boolean122130 fun loadDecoySlot(): StoredVault?123131 fun writeDecoySlot(slot: StoredVault): Boolean@@ -127,6 +135,9 @@127135 fun stampLocalMutation(): Long128136 fun loadSettings(): VaultSettings129137 fun writeSettings(settings: VaultSettings): Boolean138+ fun loadVaultHistory(): VaultHistory = VaultHistory()139+ fun writeVaultHistory(history: VaultHistory): Boolean = true140+ fun clearVaultHistory() {}130141 fun clearVault()131142 }132143 @@ -150,6 +161,8 @@150161 override fun writeSlotA(slot: StoredVault): Boolean =151162 prefs.edit().putString(KEY_SLOT_A, VaultCodec.encodeSlot(slot)).commit()152163 164+ override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)165+153166 override fun writeSlotB(blobBase64: String): Boolean =154167 prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()155168 @@ -173,9 +186,19 @@173186 override fun writeSettings(settings: VaultSettings): Boolean =174187 prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()175188 189+ override fun loadVaultHistory(): VaultHistory =190+ prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()191+192+ override fun writeVaultHistory(history: VaultHistory): Boolean =193+ prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()194+195+ override fun clearVaultHistory() {196+ prefs.edit().remove(KEY_HISTORY).commit()197+ }198+176199 override fun clearVault() {177200 prefs.edit()178- .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_VERSION).remove(KEY_SETTINGS)201+ .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)179202 .commit()180203 }181204 @@ -186,5 +209,6 @@186209 private const val KEY_SLOT_DURESS = "peach_vault_duress"187210 private const val KEY_VERSION = "peach_last_modified_at"188211 private const val KEY_SETTINGS = "peach_settings"212+ private const val KEY_HISTORY = "peach_vault_history"189213 }190214 }app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+36 −1
@@ -16,6 +16,7 @@1616 override fun loadSlotA() = slotA1717 override fun hasVault() = slotA != null1818 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19+ override fun loadSlotB() = slotB1920 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }2021 var decoy: StoredVault? = null2122 override fun loadDecoySlot() = decoy@@ -26,7 +27,11 @@2627 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }2728 override fun loadSettings() = settings2829 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }29- override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }30+ var history = VaultHistory()31+ override fun loadVaultHistory() = history32+ override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }33+ override fun clearVaultHistory() { history = VaultHistory() }34+ override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }3035 }3136 3237 class VaultRepositoryTest {@@ -109,6 +114,36 @@109114 110115 assertEquals(listOf(newer.id), repo.getEntries().map { it.id })111116 assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })117+ }118+119+ @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {120+ val repo = VaultRepository(FakeStorage())121+ repo.createVault("pw")122+123+ repeat(7) { i ->124+ repo.addEntry(login("Entry $i"))125+ Thread.sleep(1)126+ }127+128+ val history = repo.getVaultHistory()129+ assertEquals(5, history.size)130+ assertEquals("local_save", history.first().reason)131+ assertEquals(10, history.first().entryCount)132+ }133+134+ @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {135+ val repo = VaultRepository(FakeStorage())136+ repo.createVault("pw")137+ val first = repo.addEntry(login("First"))138+ repo.addEntry(login("Second"))139+ val snapshot = repo.getVaultHistory().first()140+141+ assertTrue(repo.restoreVaultSnapshot(snapshot.id))142+ assertFalse(repo.isUnlocked.value)143+ assertEquals(UnlockResult.Success, repo.unlock("pw"))144+145+ assertEquals(listOf(first.id), repo.getEntries().map { it.id })146+ assertEquals("restore", repo.getVaultHistory().first().reason)112147 }113148 114149 @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {