package com.peachpasswords.credentials import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.VaultEntry data class PasswordCredentialCandidate( val entryId: String, val title: String, val username: String, val lastUsedAt: Long?, ) /** * Content-based matching for Credential Manager password rows — the same * stance as autofill (AutofillResponseBuilder): registrable-domain matching * for web callers, verified app-link domains for native callers, and an exact * package-name association for entries the user saved from this app. A native * app's display label is never a match key (PAI-2026-001). * No caller-trust gate (removed queue #3); the caller package itself is the * framework-authenticated identity. */ object PasswordCredentialPlanner { fun candidates( entries: List, domains: Set, packageName: String? = null, allowedUserIds: Set? = null, signerDigests: Set = emptySet(), ): List { val eligible = entries.filter { it.fillable() } val byDomain = if (domains.isEmpty()) { emptyList() } else { eligible.filter { entry -> domains.any { entry.matchesDomain(it) } } } val byPackage = if (packageName.isNullOrBlank() || signerDigests.isEmpty()) { emptyList() } else { eligible.filter { (it.url == packageName || it.additionalUrls.orEmpty().any { url -> url == packageName }) && it.nativeSignerDigests.orEmpty().any { digest -> digest in signerDigests } } } return (byDomain + byPackage) .distinctBy { it.id } .mapNotNull { entry -> val username = entry.username ?: entry.email ?: return@mapNotNull null if (!allowedUserIds.isNullOrEmpty() && username !in allowedUserIds) { return@mapNotNull null } PasswordCredentialCandidate( entryId = entry.id, title = entry.title, username = username, lastUsedAt = entry.lastUsedAt, ) } .sortedWith( compareByDescending { it.lastUsedAt ?: Long.MIN_VALUE } .thenBy { it.username.lowercase() }, ) } /** Entries that can actually back a password credential row. */ private fun VaultEntry.fillable(): Boolean = deletedAt == null && !password.isNullOrBlank() private fun VaultEntry.matchesDomain(domain: String): Boolean = sequenceOf(url) .plus(additionalUrls.orEmpty().asSequence()) .filterNotNull() .mapNotNull { extractDomain(it) } .any { it == domain } }