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.PasswordGenerator 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 { /** * Registrable-domain matching. Missing, malformed, local, and IP origins * return no entries (there is nothing to rank against). */ 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)) } /** * Rank entries for an origin with no caller-trust gate (origin verification * removed, queue #3). Web origins match by the page domain; native apps match * by their app-link domains or display title. Blank origins match nothing. */ fun matchEntries(entries: List, parsed: ParsedStructure): MatchResult { val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet() val byDomain = matchDomains(entries, domains).hits val appTitle = parsed.appTitle?.trim() val byTitle = if (appTitle.isNullOrBlank()) { emptyList() } else { entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) } } return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList()) } private fun matchDomains(entries: List, domains: Set): MatchResult = MatchResult( hits = entries.filter { entry -> domains.any { domain -> entry.matchesOriginDomain(domain) } }, rest = emptyList(), ) /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */ internal fun otpDigitsFor(boxCount: Int, code: String): List = code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() } private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean = sequenceOf(url) .plus(additionalUrls.orEmpty().asSequence()) .filterNotNull() .mapNotNull(::extractDomain) .any { it == originDomain } /** * What a suggestion row or keyboard chip shows about an entry. * [iconSource] seeds the chip monogram; null keeps the Peach mark * (always the case in privacy mode — initials would leak titles too). */ data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?) /** * Privacy previews: with [privatePreview] the rows and chips render only * the Peach brand and the caller's domain — no entry titles or usernames * in the IME's suggestion strip. The values still fill on tap. */ fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels = if (privatePreview) { PreviewLabels("Peach", originDomain ?: "", null) } else { PreviewLabels( entry.title, entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "", entry.title, ) } fun rowFor(context: Context, labels: PreviewLabels): RemoteViews = RemoteViews(context.packageName, R.layout.autofill_row).apply { setTextViewText(R.id.autofill_row_title, labels.title) setTextViewText(R.id.autofill_row_subtitle, labels.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?, privatePreview: Boolean, ): Dataset? { val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain)) val menu = rowFor(context, labels) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, labels, 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) val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) } put(parsed.otpId, otpCode) // A6: split single-character OTP boxes — one character per box // (browser's fillOTPDigitBoxes). Only when no full-code field exists. if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) { otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit -> builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu) } filledAny = true } 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?, privatePreview: Boolean, ): Dataset? { if (!entry.isCard()) return null val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain)) val menu = rowFor(context, labels) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, labels, 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 } /** * A "generate strong password" dataset for signup / no-match forms. Fills the * detected password field with a freshly generated password sized to the * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends * up in the password field, so the generated value is offered for save like * any other fill. Menu-only (no inline chip) — there is no vault entry to * render. Parity for the browser's inline generator on create forms. */ fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? { val passwordId = parsed.passwordId ?: return null val policy = parsed.passwordPolicy val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length)) val note = policy?.constraintNote(length).orEmpty() val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" }) return Dataset.Builder() .setValue(passwordId, AutofillValue.forText(password), menu) .build() } /** * A4 identity helper: a dataset filling ONLY the signup identity field. * Email identities prefer the email field, usernames the username field; * each falls back to the other when its preferred field is absent. */ fun identityDatasetFor( context: Context, parsed: ParsedStructure, identity: SavedIdentity, ): Dataset? { val targetId = when (identity.type) { IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId } ?: return null val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username" val menu = simpleRow(context, identity.value, subtitle) return Dataset.Builder() .setValue(targetId, AutofillValue.forText(identity.value), menu) .build() } // 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, labels: PreviewLabels, spec: android.widget.inline.InlinePresentationSpec, ): InlinePresentation? = try { val style = spec.style if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) { null } else { val pending = AutofillIntents.noopChipIntent(context) val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) } ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark) val slice = InlineSuggestionUi.newContentBuilder(pending) .setTitle(labels.title) .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) } .setStartIcon(startIcon) .build() .slice InlinePresentation(slice, spec, false) } } catch (_: Throwable) { // A non-conforming IME spec must never crash the fill — degrade to menu. null } }