package com.peachpasswords.autofill import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.service.autofill.Dataset import android.service.autofill.InlinePresentation import android.view.autofill.AutofillId import android.view.autofill.AutofillValue import android.widget.RemoteViews import androidx.annotation.RequiresApi import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.autofill.inline.UiVersions import com.peachpasswords.R import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.isCard /** * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu * RemoteViews row, the inline keyboard chip, and the actual field values. * TOTP is filled as a freshly-generated code; the seed never leaves the vault. */ object AutofillResponseBuilder { /** * Strict registrable-domain matching. Missing, malformed, local, and IP origins * return no entries; callers must route those requests through the guarded picker. */ data class MatchResult(val hits: List, val rest: List) { val all: List get() = hits + rest } fun matchEntries(entries: List, origin: String?): MatchResult { val originDomain = origin?.let(::extractDomain) ?: return MatchResult(emptyList(), emptyList()) return matchDomains(entries, setOf(originDomain)) } fun matchEntries(entries: List, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) { AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain) AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains( entries, parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(), ) AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList()) } private fun matchDomains(entries: List, domains: Set): MatchResult = MatchResult( hits = entries.filter { entry -> domains.any { domain -> entry.matchesOriginDomain(domain) } }, rest = emptyList(), ) private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean = sequenceOf(url) .plus(additionalUrls.orEmpty().asSequence()) .filterNotNull() .mapNotNull(::extractDomain) .any { it == originDomain } fun menuRow(context: Context, entry: VaultEntry): RemoteViews { val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" return RemoteViews(context.packageName, R.layout.autofill_row).apply { setTextViewText(R.id.autofill_row_title, entry.title) setTextViewText(R.id.autofill_row_subtitle, subtitle) } } fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews = RemoteViews(context.packageName, R.layout.autofill_row).apply { setTextViewText(R.id.autofill_row_title, title) setTextViewText(R.id.autofill_row_subtitle, subtitle) } /** * Build a dataset filling the parsed fields from [entry]. Attaches the inline * chip when the IME offered a spec (API 30+); otherwise menu-only. */ fun datasetFor( context: Context, parsed: ParsedStructure, entry: VaultEntry, inlineSpec: android.widget.inline.InlinePresentationSpec?, ): Dataset? { val menu = menuRow(context, entry) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, entry, inlineSpec) } else { null } val builder = Dataset.Builder() var filledAny = false fun put(id: AutofillId?, value: String?) { if (id == null || value.isNullOrEmpty()) return val av = AutofillValue.forText(value) if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(id, av, menu, inline) } else { builder.setValue(id, av, menu) } filledAny = true } put(parsed.identityId, entry.username ?: entry.email) put(parsed.passwordId, entry.password) put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) }) return if (filledAny) builder.build() else null } /** * Build a dataset filling detected card fields from a card [entry]. * Cards are not domain-matched — any detected card form offers all cards. */ fun cardDatasetFor( context: Context, parsed: ParsedStructure, entry: VaultEntry, inlineSpec: android.widget.inline.InlinePresentationSpec?, ): Dataset? { if (!entry.isCard()) return null val menu = menuRow(context, entry) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, entry, inlineSpec) } else { null } val builder = Dataset.Builder() var filledAny = false fun put(id: AutofillId?, value: String?) { if (id == null || value.isNullOrEmpty()) return val av = AutofillValue.forText(value) if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(id, av, menu, inline) } else { builder.setValue(id, av, menu) } filledAny = true } val digits = entry.cardNumber?.filter { it.isDigit() } put(parsed.cardNumberId, digits) put(parsed.cardExpMonthId, entry.expMonth) put(parsed.cardExpYearId, entry.expYear) if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) { val yy = entry.expYear.takeLast(2) put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy") } put(parsed.cardCvcId, entry.cvc) put(parsed.cardNameId, entry.cardholderName) return if (filledAny) builder.build() else null } // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but // reading the slice to build an InlinePresentation is the documented usage. @SuppressLint("RestrictedApi") @RequiresApi(30) private fun buildInline( context: Context, entry: VaultEntry, spec: android.widget.inline.InlinePresentationSpec, ): InlinePresentation? = try { val style = spec.style if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) { null } else { val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" val pending = AutofillIntents.noopChipIntent(context) val slice = InlineSuggestionUi.newContentBuilder(pending) .setTitle(entry.title) .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) } .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)) .build() .slice InlinePresentation(slice, spec, false) } } catch (_: Throwable) { // A non-conforming IME spec must never crash the fill — degrade to menu. null } }