PasswordCredentialPlanner.kt

2.8 KB · raw

1package com.peachpasswords.credentials23import com.peachpasswords.shared.extractDomain4import com.peachpasswords.vault.VaultEntry56data class PasswordCredentialCandidate(7    val entryId: String,8    val title: String,9    val username: String,10    val lastUsedAt: Long?,11)1213/**14 * Content-based matching for Credential Manager password rows — the same15 * stance as autofill (AutofillResponseBuilder): registrable-domain matching16 * for web callers, verified app-link domains for native callers, and an exact17 * package-name association for entries the user saved from this app. A native18 * app's display label is never a match key (PAI-2026-001).19 * No caller-trust gate (removed queue #3); the caller package itself is the20 * framework-authenticated identity.21 */22object PasswordCredentialPlanner {2324    fun candidates(25        entries: List<VaultEntry>,26        domains: Set<String>,27        packageName: String? = null,28        allowedUserIds: Set<String>? = null,29        signerDigests: Set<String> = emptySet(),30    ): List<PasswordCredentialCandidate> {31        val eligible = entries.filter { it.fillable() }32        val byDomain = if (domains.isEmpty()) {33            emptyList()34        } else {35            eligible.filter { entry -> domains.any { entry.matchesDomain(it) } }36        }37        val byPackage = if (packageName.isNullOrBlank() || signerDigests.isEmpty()) {38            emptyList()39        } else {40            eligible.filter {41                (it.url == packageName || it.additionalUrls.orEmpty().any { url -> url == packageName }) &&42                    it.nativeSignerDigests.orEmpty().any { digest -> digest in signerDigests }43            }44        }45        return (byDomain + byPackage)46            .distinctBy { it.id }47            .mapNotNull { entry ->48                val username = entry.username ?: entry.email ?: return@mapNotNull null49                if (!allowedUserIds.isNullOrEmpty() && username !in allowedUserIds) {50                    return@mapNotNull null51                }52                PasswordCredentialCandidate(53                    entryId = entry.id,54                    title = entry.title,55                    username = username,56                    lastUsedAt = entry.lastUsedAt,57                )58            }59            .sortedWith(60                compareByDescending<PasswordCredentialCandidate> { it.lastUsedAt ?: Long.MIN_VALUE }61                    .thenBy { it.username.lowercase() },62            )63    }6465    /** Entries that can actually back a password credential row. */66    private fun VaultEntry.fillable(): Boolean =67        deletedAt == null && !password.isNullOrBlank()6869    private fun VaultEntry.matchesDomain(domain: String): Boolean =70        sequenceOf(url)71            .plus(additionalUrls.orEmpty().asSequence())72            .filterNotNull()73            .mapNotNull { extractDomain(it) }74            .any { it == domain }75}