827354a8a29d

feat: Add vault history restore (v2.3.21)

- Snapshot encrypted vault slots before writes and cap history to browser behavior - Render Vault card history rows with restore-to-lock flow - Verify repository tests and full Gradle gate

time
2026-06-26 08:01 UTC · 4 months ago
machine
koh2
parent
94361110a63a
6 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 −1
app/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 {

view file

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  ────────────────────────────────── */

view file

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 

view file

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)

view file

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 }

view file

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 {

view file