Identities.kt

3.6 KB · raw

1package com.peachpasswords.autofill23import com.peachpasswords.shared.extractDomain4import com.peachpasswords.vault.VaultEntry56/**7 * Signup identities for the autofill picker (parity gap A4). Ports the8 * browser's `getIdentities()` (background/vault.ts) and `identity-helper.ts`:9 * identities are DERIVED from active login entries — deduplicated10 * usernames/emails with usage tallies — not separate vault objects.11 */1213enum class IdentityType { EMAIL, USERNAME }1415enum class IdentityFilterMode(val label: String) {16    BOTH("Both"),17    EMAIL("Emails"),18    USERNAME("Usernames"),19}2021data class SavedIdentity(22    val value: String,23    val type: IdentityType,24    val lastUsed: Long,25    val useCount: Int,26    val exampleDomain: String? = null,27)2829private val EMAIL_RE = Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")3031private class Tally(var lastUsed: Long, var count: Int, var domain: String)3233/** Dedupe active login entries' usernames/emails into usage-ordered identities. */34internal fun deriveIdentities(entries: List<VaultEntry>): List<SavedIdentity> {35    val active = entries.filter { it.deletedAt == null && it.type == "login" }36    val emailMap = HashMap<String, Tally>()37    val usernameMap = HashMap<String, Tally>()3839    fun tally(map: HashMap<String, Tally>, value: String, modifiedAt: Long, domain: String) {40        val existing = map[value]41        if (existing == null) {42            map[value] = Tally(modifiedAt, 1, domain)43        } else {44            existing.count++45            if (modifiedAt > existing.lastUsed) {46                existing.lastUsed = modifiedAt47                existing.domain = domain48            }49        }50    }5152    for (entry in active) {53        val domain = entry.url?.let(::extractDomain) ?: ""54        entry.username?.trim()?.takeIf { it.isNotEmpty() }?.let { value ->55            tally(if (EMAIL_RE.matches(value)) emailMap else usernameMap, value, entry.modifiedAt, domain)56        }57        // Count the dedicated email field too — entries created via the58        // new-entry editor store email here rather than in username.59        entry.email?.trim()?.takeIf { it.isNotEmpty() }?.let { value ->60            tally(emailMap, value, entry.modifiedAt, domain)61        }62    }6364    fun toIdentities(map: HashMap<String, Tally>, type: IdentityType) =65        map.entries66            .map { (value, t) ->67                SavedIdentity(value, type, t.lastUsed, t.count, t.domain.takeIf { it.isNotEmpty() })68            }69            .sortedByDescending { it.useCount }7071    return toIdentities(emailMap, IdentityType.EMAIL) + toIdentities(usernameMap, IdentityType.USERNAME)72}7374/** Browser `filterSavedIdentities`: type filter, usage order, then substring. */75internal fun filterIdentities(76    identities: List<SavedIdentity>,77    query: String,78    mode: IdentityFilterMode,79): List<SavedIdentity> {80    val typeFiltered = when (mode) {81        IdentityFilterMode.EMAIL -> identities.filter { it.type == IdentityType.EMAIL }82        IdentityFilterMode.USERNAME -> identities.filter { it.type == IdentityType.USERNAME }83        IdentityFilterMode.BOTH -> identities84    }85    val sorted = typeFiltered.sortedWith(86        compareByDescending<SavedIdentity> { it.lastUsed }87            .thenByDescending { it.useCount }88            .thenBy { it.value }89            .thenBy { it.type.name },90    )91    val trimmed = query.trim().lowercase()92    if (trimmed.isEmpty()) return sorted93    return sorted.filter { identity ->94        listOf(identity.value, identity.exampleDomain ?: "", identity.type.name.lowercase())95            .joinToString(" ")96            .lowercase()97            .contains(trimmed)98    }99}