7f1a2d6e3a0b
feat: Pro advanced vault health report — ranked remediation queue (parity V1) (v2.10.5)
- time
- 2026-08-02 20:05 UTC · 2 months ago
- machine
- koh2
- parent
- fdee8ada5fe3
↓ peach-android-7f1a2d6e.tar.gzatlas at this save
files changed4 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 −0app/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 @Composableapp/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 }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+}