package com.peachpasswords.autofill import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.VaultEntry /** * Signup identities for the autofill picker (parity gap A4). Ports the * browser's `getIdentities()` (background/vault.ts) and `identity-helper.ts`: * identities are DERIVED from active login entries — deduplicated * usernames/emails with usage tallies — not separate vault objects. */ enum class IdentityType { EMAIL, USERNAME } enum class IdentityFilterMode(val label: String) { BOTH("Both"), EMAIL("Emails"), USERNAME("Usernames"), } data class SavedIdentity( val value: String, val type: IdentityType, val lastUsed: Long, val useCount: Int, val exampleDomain: String? = null, ) private val EMAIL_RE = Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$") private class Tally(var lastUsed: Long, var count: Int, var domain: String) /** Dedupe active login entries' usernames/emails into usage-ordered identities. */ internal fun deriveIdentities(entries: List): List { val active = entries.filter { it.deletedAt == null && it.type == "login" } val emailMap = HashMap() val usernameMap = HashMap() fun tally(map: HashMap, value: String, modifiedAt: Long, domain: String) { val existing = map[value] if (existing == null) { map[value] = Tally(modifiedAt, 1, domain) } else { existing.count++ if (modifiedAt > existing.lastUsed) { existing.lastUsed = modifiedAt existing.domain = domain } } } for (entry in active) { val domain = entry.url?.let(::extractDomain) ?: "" entry.username?.trim()?.takeIf { it.isNotEmpty() }?.let { value -> tally(if (EMAIL_RE.matches(value)) emailMap else usernameMap, value, entry.modifiedAt, domain) } // Count the dedicated email field too — entries created via the // new-entry editor store email here rather than in username. entry.email?.trim()?.takeIf { it.isNotEmpty() }?.let { value -> tally(emailMap, value, entry.modifiedAt, domain) } } fun toIdentities(map: HashMap, type: IdentityType) = map.entries .map { (value, t) -> SavedIdentity(value, type, t.lastUsed, t.count, t.domain.takeIf { it.isNotEmpty() }) } .sortedByDescending { it.useCount } return toIdentities(emailMap, IdentityType.EMAIL) + toIdentities(usernameMap, IdentityType.USERNAME) } /** Browser `filterSavedIdentities`: type filter, usage order, then substring. */ internal fun filterIdentities( identities: List, query: String, mode: IdentityFilterMode, ): List { val typeFiltered = when (mode) { IdentityFilterMode.EMAIL -> identities.filter { it.type == IdentityType.EMAIL } IdentityFilterMode.USERNAME -> identities.filter { it.type == IdentityType.USERNAME } IdentityFilterMode.BOTH -> identities } val sorted = typeFiltered.sortedWith( compareByDescending { it.lastUsed } .thenByDescending { it.useCount } .thenBy { it.value } .thenBy { it.type.name }, ) val trimmed = query.trim().lowercase() if (trimmed.isEmpty()) return sorted return sorted.filter { identity -> listOf(identity.value, identity.exampleDomain ?: "", identity.type.name.lowercase()) .joinToString(" ") .lowercase() .contains(trimmed) } }