package com.peachpasswords.autofill import android.content.Context import android.content.pm.verify.domain.DomainVerificationManager import android.content.pm.verify.domain.DomainVerificationUserState import android.os.Build import com.peachpasswords.shared.extractDomain import com.peachpasswords.passkeys.PasskeyCallerOrigin import com.peachpasswords.vault.VaultEntry /** * Origin resolution for autofill matching. * * The caller package comes from the framework-owned activity component. A raw * ViewNode.webDomain is only accepted when that package is a signed browser in * the same allowlist used by passkeys; otherwise the request fails closed. */ /** * Populate [ParsedStructure.originDomains] from the authenticated origin. * Verified browsers match on the page domain; native apps match on their * verified app-link domains. Unverified web claims produce no origin matches. * * An app's display label is never an origin: it is user-visible presentation a * developer chooses freely, so any sideloaded app can claim the label of a * vault entry (PAI-2026-001). Native apps without verified app-link domains * therefore get no automatic rows and reach the vault through the picker's * explicit search. */ internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure { if (parsed.webDomain != null) { return applyWebOriginVerification( parsed, PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName), ) } return parsed.copy( nativeSignerDigests = parsed.packageName?.let { PasskeyCallerOrigin.signerFingerprints(context, it) }.orEmpty(), originDomains = appLinkDomains(context, parsed.packageName), ) } /** Preserve an unverified web claim as a deny decision instead of reinterpreting it as native. */ internal fun applyWebOriginVerification( parsed: ParsedStructure, verifiedBrowser: Boolean, ): ParsedStructure { val webDomain = parsed.webDomain ?: return parsed return if (verifiedBrowser) { parsed.copy( originDomains = listOfNotNull(extractDomain(webDomain)), credentialReleaseAllowed = true, ) } else { parsed.copy( webDomain = null, originDomains = emptyList(), credentialReleaseAllowed = false, ) } } /** A native app's verified app-link domains (Android's cached Digital Asset Links verdict). */ internal fun appLinkDomains(context: Context, packageName: String?): List { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || packageName.isNullOrBlank()) { return emptyList() } return runCatching { val state = context.getSystemService(DomainVerificationManager::class.java) ?.getDomainVerificationUserState(packageName) state?.hostToStateMap.orEmpty() .filterValues { it == DomainVerificationUserState.DOMAIN_STATE_VERIFIED } .keys .mapNotNull { extractDomain(it.removePrefix("*.")) } .distinct() .sorted() }.getOrDefault(emptyList()) } /** * Domains to offer to "remember" when the user manually picks [entry] for this * origin (Android counterpart of the browser's remember-this-site toast). * Mirrors the browser trigger: only offer when the entry already carries at * least one known domain and none of them cover this origin. Empty = no offer. */ internal fun rememberOfferDomains(entry: VaultEntry, parsed: ParsedStructure): List { val existing = sequenceOf(entry.url) .plus(entry.additionalUrls.orEmpty().asSequence()) .filterNotNull() .mapNotNull(::extractDomain) .toSet() if (existing.isEmpty() || parsed.originDomains.isEmpty()) return emptyList() return parsed.originDomains.filter { it !in existing } }