7f1a2d6e3a0b

feat: Pro advanced vault health report — ranked remediation queue (parity V1) (v2.10.5)

Browser parity gap V1 (MEDIUM). The scoring engine already existed (VaultHealth.buildReport) and ProFeature.AdvancedHealth was already defined; what was missing was the advanced report shape and its UI. - VaultHealth.buildAdvancedReport: reuses the existing helpers to produce the browser's advanced shape — per-entry reasons + factors (with breach count), age in days, a ranked remediation queue (score>0, sorted desc), reused-group count, and a sensitive-weighted (2x) vault score. Faithful to peach-browser background/health.ts, including the passwordChangedAt ?: createdAt age fallback - VaultHealthViewModel: inject LicenseRepository; expose advanced + isPro via hasFeature(ProFeature.AdvancedHealth); recompute refreshes both reports - VaultHealthScreen: new Priorities section — locked upsell for free users, and for Pro a ranked list of remediation rows (favicon, title, domain, reasons, color-coded score badge, tap to edit) - VaultHealthAdvancedTest: 11 tests (reuse groups, weak/aged/breached scoring, sensitive 90d + 2x weighting, 100 cap, sort + zero-exclusion, exclusions) Live UI needs on-device QA.

time
2026-08-02 20:05 UTC · 2 months ago
machine
koh2
parent
fdee8ada5fe3
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/vault/VaultHealthScreen.kt+98 −2app/src/main/java/com/peachpasswords/vault/VaultHealth.kt+123 −0app/src/test/java/com/peachpasswords/vault/VaultHealthAdvancedTest.kt+133 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.41+2.10.5

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultHealthScreen.kt+98 −2
@@ -52,6 +52,8 @@5252 import com.peachpasswords.ui.theme.peachColors5353 import com.peachpasswords.ui.theme.PeachType5454 import com.peachpasswords.ui.theme.ShellBackground55+import com.peachpasswords.license.LicenseRepository56+import com.peachpasswords.license.ProFeature5557 import com.peachpasswords.vault.VaultEntry5658 import com.peachpasswords.vault.VaultHealth5759 import com.peachpasswords.vault.VaultRepository@@ -76,15 +78,24 @@7678 @HiltViewModel7779 class VaultHealthViewModel @Inject constructor(7880     private val vault: VaultRepository,81+    private val license: LicenseRepository,7982 ) : ViewModel() {8083     val entries: StateFlow<List<VaultEntry>> = vault.entries8184 8285     private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))8386     val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8487 85-    /** Re-read the live entry list and recompute the local health report. */88+    private val _advanced = MutableStateFlow(VaultHealth.buildAdvancedReport(vault.getEntries()))89+    val advanced: StateFlow<VaultHealth.AdvancedHealthReport> = _advanced.asStateFlow()90+91+    /** Advanced health is Pro-gated (ProFeature.AdvancedHealth). */92+    val isPro: Boolean get() = license.hasFeature(ProFeature.AdvancedHealth)93+94+    /** Re-read the live entry list and recompute the local health reports. */8695     fun recompute() {87-        _report.value = VaultHealth.buildReport(vault.getEntries())96+        val snapshot = vault.getEntries()97+        _report.value = VaultHealth.buildReport(snapshot)98+        _advanced.value = VaultHealth.buildAdvancedReport(snapshot)8899     }89100 }90101 @@ -97,6 +108,8 @@97108     val accent = LocalPeachAccent.current98109     val entries by vm.entries.collectAsState()99110     val report by vm.report.collectAsState()111+    val advanced by vm.advanced.collectAsState()112+    val isPro = vm.isPro100113 101114     val byId = remember(entries) { entries.associateBy { it.id } }102115     val flags = remember(report) { report.entries.associateBy { it.entryId } }@@ -167,6 +180,15 @@167180                     val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)168181                         .coerceAtLeast(0.dp)169182                     LazyColumn(Modifier.fillMaxSize()) {183+                        item(key = "advanced") {184+                            AdvancedHealthSection(185+                                advanced = advanced,186+                                isPro = isPro,187+                                expanded = expanded["Advanced"] == true,188+                                onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },189+                                onEditEntry = onEditEntry,190+                            )191+                        }170192                         healthSection("Breached", breached, breachColor, expanded, onEditEntry)171193                         healthSection("Reused", reused, Amber, expanded, onEditEntry)172194                         healthSection("Weak", weak, Amber, expanded, onEditEntry)@@ -255,6 +277,80 @@255277         }256278         GhostPill("Change password", LocalPeachAccent.current, onChange)257279     }280+}281+282+@Composable283+private fun AdvancedHealthSection(284+    advanced: VaultHealth.AdvancedHealthReport,285+    isPro: Boolean,286+    expanded: Boolean,287+    onToggle: () -> Unit,288+    onEditEntry: (String) -> Unit,289+) {290+    SectionHeader(291+        title = "Priorities",292+        count = if (isPro) advanced.remediation.size else 0,293+        badgeColor = LocalPeachAccent.current,294+        expanded = expanded,295+        onToggle = onToggle,296+    )297+    if (expanded) {298+        when {299+            !isPro -> Text(300+                "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",301+                color = peachColors.T2,302+                style = PeachType.tinyMono,303+                modifier = Modifier.fillMaxWidth()304+                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),305+            )306+            advanced.remediation.isEmpty() -> Text(307+                "Nothing needs attention.",308+                color = peachColors.T2,309+                style = PeachType.tinyMono,310+                modifier = Modifier.fillMaxWidth()311+                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),312+            )313+            else -> advanced.remediation.forEach { r ->314+                AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })315+            }316+        }317+    }318+}319+320+@Composable321+private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {322+    Row(323+        verticalAlignment = Alignment.CenterVertically,324+        modifier = Modifier325+            .fillMaxWidth()326+            .clip(RoundedCornerShape(10.dp))327+            .clickable(onClick = onClick)328+            .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),329+    ) {330+        FaviconInitials(source = report.url ?: report.title, size = 26.dp)331+        Spacer(Modifier.width(PeachSpacing.sm))332+        Column(Modifier.weight(1f)) {333+            Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)334+            if (report.domain.isNotBlank()) {335+                Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)336+            }337+            if (report.reasons.isNotEmpty()) {338+                Text(339+                    report.reasons.joinToString(" · "),340+                    color = LocalPeachAccent.current.copy(alpha = 0.7f),341+                    style = PeachType.tinyMono,342+                )343+            }344+        }345+        CountBadge(report.score, scoreColor(report.score))346+    }347+}348+349+@Composable350+private fun scoreColor(score: Int): Color = when {351+    score >= 60 -> peachColors.Breach352+    score >= 25 -> Amber353+    else -> peachColors.T2258354 }259355 260356 @Composable

view file

app/src/main/java/com/peachpasswords/vault/VaultHealth.kt+123 −0
@@ -2,6 +2,7 @@22 33 import com.peachpasswords.shared.extractDomain44 import java.security.MessageDigest5+import kotlin.math.roundToInt56 67 object VaultHealth {78     private val SENSITIVE_PATTERN = Regex(@@ -99,4 +100,126 @@99100         val hash = digest.digest(input.toByteArray(Charsets.UTF_8))100101         return hash.joinToString("") { "%02x".format(it) }101102     }103+104+    // ---- Advanced report (Pro, parity V1) ---------------------------------105+    // Ports peach-browser background/health.ts buildAdvancedHealthReport. Shares106+    // the scoring engine above (same pattern/thresholds/weights via the private107+    // helpers); adds per-entry reasons + factors, age in days, a ranked108+    // remediation queue, and a reused-group count for the Pro health surface.109+    // ponytail: buildReport and buildAdvancedReport duplicate the weight loop;110+    //   fold into one shared per-entry scorer if a third consumer appears.111+112+    data class AdvancedFactors(113+        val breached: Boolean,114+        val breachCount: Int,115+        val reused: Boolean,116+        val aged: Boolean,117+        val weak: Boolean,118+        val missingTotpOnSensitive: Boolean,119+    )120+121+    data class AdvancedEntryReport(122+        val id: String,123+        val title: String,124+        val domain: String,125+        val url: String?,126+        val score: Int,127+        val ageDays: Long,128+        val reasons: List<String>,129+        val factors: AdvancedFactors,130+    )131+132+    data class AdvancedHealthReport(133+        val vaultScore: Int,134+        val generatedAt: Long,135+        val entryCount: Int,136+        val reusedGroups: Int,137+        val agedCount: Int,138+        val remediation: List<AdvancedEntryReport>,139+    )140+141+    fun buildAdvancedReport(142+        entries: List<VaultEntry>,143+        now: Long = System.currentTimeMillis(),144+    ): AdvancedHealthReport {145+        val live = entries.filter { it.isLogin() && !it.isDeleted() }146+147+        val hashGroups = HashMap<String, MutableList<String>>()148+        for (entry in live) {149+            val pw = entry.password ?: continue150+            hashGroups.getOrPut(sha256Hex(pw)) { mutableListOf() }.add(entry.id)151+        }152+        val reusedIds = HashSet<String>()153+        var reusedGroups = 0154+        for (ids in hashGroups.values) {155+            if (ids.size > 1) {156+                reusedGroups++157+                reusedIds.addAll(ids)158+            }159+        }160+161+        val reports = mutableListOf<AdvancedEntryReport>()162+        var agedCount = 0163+        var weightedSum = 0L164+        var weightSum = 0L165+        for (entry in live) {166+            val domain = entry.url?.let { extractDomain(it) }.orEmpty()167+            val sensitive = domain.isNotEmpty() && SENSITIVE_PATTERN.containsMatchIn(domain)168+            val ageMs = now - (entry.passwordChangedAt ?: entry.createdAt)169+            val aged = entry.password != null &&170+                ageMs > if (sensitive) SENSITIVE_AGE_MS else STANDARD_AGE_MS171+            if (aged) agedCount++172+173+            val breachCount = entry.breach ?: 0174+            val breached = breachCount > 0175+            val reused = reusedIds.contains(entry.id)176+            val weak = entry.password?.let { it.length < 10 || countCharClasses(it) < 3 } ?: false177+            val missingTotp = sensitive && entry.totp.isNullOrBlank()178+179+            var score = 0180+            if (breached) score += 60181+            if (reused) score += 25182+            if (aged) score += 15183+            if (weak) score += 20184+            if (missingTotp) score += 10185+            if (score > 100) score = 100186+187+            val reasons = buildList {188+                if (breached) add("Breached ($breachCount exposures)")189+                if (reused) add("Reused")190+                if (aged) add("${ageMs / DAY_MS} days old")191+                if (weak) add("Weak")192+                if (missingTotp) add("No 2FA on sensitive site")193+            }194+195+            val weight = if (sensitive) 2L else 1L196+            weightedSum += score.toLong() * weight197+            weightSum += weight198+199+            if (score > 0) {200+                reports += AdvancedEntryReport(201+                    id = entry.id,202+                    title = entry.title,203+                    domain = domain,204+                    url = entry.url,205+                    score = score,206+                    ageDays = ageMs / DAY_MS,207+                    reasons = reasons,208+                    factors = AdvancedFactors(breached, breachCount, reused, aged, weak, missingTotp),209+                )210+            }211+        }212+213+        val vaultScore = if (weightSum > 0L) (weightedSum.toFloat() / weightSum).roundToInt() else 0214+        return AdvancedHealthReport(215+            vaultScore = vaultScore,216+            generatedAt = now,217+            entryCount = live.size,218+            reusedGroups = reusedGroups,219+            agedCount = agedCount,220+            remediation = reports.sortedByDescending { it.score },221+        )222+    }223+224+    private const val DAY_MS = 86_400_000L102225 }

view file

app/src/test/java/com/peachpasswords/vault/VaultHealthAdvancedTest.kt+133 −0
@@ -0,0 +1,133 @@1+package com.peachpasswords.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class VaultHealthAdvancedTest {9+10+    private val now = 1_000_000_000_000L11+    private val day = 86_400_000L12+13+    private fun entry(14+        id: String,15+        url: String? = "https://example.com",16+        password: String? = "Str0ng!Passw0rd", // 15 chars, 4 classes -> not weak17+        totp: String? = "otpauth://totp/x?secret=JBSWY3DPEHPK3PXP",18+        breach: Int? = null,19+        passwordChangedAt: Long? = now,20+        createdAt: Long = now,21+        type: String? = "login",22+        deletedAt: Long? = null,23+    ) = VaultEntry(24+        id = id,25+        title = "Entry $id",26+        type = type,27+        url = url,28+        password = password,29+        totp = totp,30+        breach = breach,31+        passwordChangedAt = passwordChangedAt,32+        createdAt = createdAt,33+        modifiedAt = createdAt,34+        deletedAt = deletedAt,35+    )36+37+    @Test fun reused_passwords_form_one_group_and_score() {38+        val a = entry("a", url = "https://one.com", password = "Sh4red!Password")39+        val b = entry("b", url = "https://two.com", password = "Sh4red!Password")40+        val report = VaultHealth.buildAdvancedReport(listOf(a, b), now)41+        assertEquals(1, report.reusedGroups)42+        val byId = report.remediation.associateBy { it.id }43+        assertTrue(byId["a"]!!.factors.reused)44+        assertTrue(byId["b"]!!.factors.reused)45+        assertEquals(25, byId["a"]!!.score)46+        assertTrue(byId["a"]!!.reasons.contains("Reused"))47+    }48+49+    @Test fun weak_short_password_flagged() {50+        val report = VaultHealth.buildAdvancedReport(listOf(entry("w", password = "short")), now)51+        val r = report.remediation.single()52+        assertTrue(r.factors.weak)53+        assertTrue(r.reasons.contains("Weak"))54+        assertEquals(20, r.score)55+    }56+57+    @Test fun non_sensitive_ages_at_180_days() {58+        val fresh = entry("fresh", password = "Fresh!Passw0rd1", passwordChangedAt = now - 100 * day)59+        val stale = entry("stale", password = "Stale!Passw0rd2", passwordChangedAt = now - 200 * day)60+        val report = VaultHealth.buildAdvancedReport(listOf(fresh, stale), now)61+        assertEquals(1, report.agedCount)62+        val staleReport = report.remediation.first { it.id == "stale" }63+        assertTrue(staleReport.factors.aged)64+        assertEquals(200L, staleReport.ageDays)65+        assertTrue(staleReport.reasons.contains("200 days old"))66+        assertEquals(15, staleReport.score)67+    }68+69+    @Test fun sensitive_domain_ages_at_90_days() {70+        // 100 days old: aged on a bank (90d threshold).71+        val bank = entry("bank", url = "https://mybank.com", passwordChangedAt = now - 100 * day)72+        val r = VaultHealth.buildAdvancedReport(listOf(bank), now).remediation.single()73+        assertTrue(r.factors.aged)74+        assertFalse(r.factors.missingTotpOnSensitive) // totp present75+        assertEquals(15, r.score)76+    }77+78+    @Test fun sensitive_without_totp_adds_reason() {79+        val bank = entry("bank", url = "https://mybank.com", totp = null)80+        val r = VaultHealth.buildAdvancedReport(listOf(bank), now).remediation.single()81+        assertTrue(r.factors.missingTotpOnSensitive)82+        assertTrue(r.reasons.contains("No 2FA on sensitive site"))83+        assertEquals(10, r.score)84+    }85+86+    @Test fun breached_carries_exposure_count() {87+        val r = VaultHealth.buildAdvancedReport(listOf(entry("x", breach = 3)), now).remediation.single()88+        assertTrue(r.factors.breached)89+        assertEquals(3, r.factors.breachCount)90+        assertTrue(r.reasons.contains("Breached (3 exposures)"))91+        assertEquals(60, r.score)92+    }93+94+    @Test fun score_caps_at_100() {95+        // breached(60) + reused(25) + weak(20) + aged(15) = 120 -> capped at 100.96+        val a = entry("a", password = "weak", breach = 1, passwordChangedAt = now - 400 * day)97+        val b = entry("b", password = "weak") // makes "weak" a reused group98+        val report = VaultHealth.buildAdvancedReport(listOf(a, b), now)99+        assertEquals(100, report.remediation.first { it.id == "a" }.score)100+    }101+102+    @Test fun remediation_sorted_desc_and_excludes_zero() {103+        val breached = entry("hi", password = "Hi!Passw0rd\$trong1", breach = 5)104+        val weak = entry("lo", password = "weak")105+        val healthy = entry("ok", password = "Ok!Passw0rd\$trong2") // score 0106+        val report = VaultHealth.buildAdvancedReport(listOf(weak, breached, healthy), now)107+        assertEquals(listOf("hi", "lo"), report.remediation.map { it.id })108+        assertTrue(report.remediation.none { it.id == "ok" })109+    }110+111+    @Test fun deleted_and_non_login_excluded_from_count() {112+        val entries = listOf(113+            entry("live"),114+            entry("gone", deletedAt = now),115+            entry("card", type = "card"),116+        )117+        assertEquals(1, VaultHealth.buildAdvancedReport(entries, now).entryCount)118+    }119+120+    @Test fun vault_score_weights_sensitive_double() {121+        val bank = entry("bank", url = "https://mybank.com", totp = null) // sensitive, score 10, weight 2122+        val weak = entry("weak", password = "weak") // score 20, weight 1123+        val report = VaultHealth.buildAdvancedReport(listOf(bank, weak), now)124+        assertEquals(13, report.vaultScore) // (10*2 + 20*1) / 3 = 13.33 -> 13125+    }126+127+    @Test fun empty_vault_yields_zero() {128+        val report = VaultHealth.buildAdvancedReport(emptyList(), now)129+        assertEquals(0, report.vaultScore)130+        assertEquals(0, report.entryCount)131+        assertTrue(report.remediation.isEmpty())132+    }133+}

view file