package com.peachpasswords.autofill import android.annotation.SuppressLint import android.app.PendingIntent 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). */ enum class FillMode { LOGIN, OTP_FILL } data class PreviewLabels( val title: String, val subtitle: String, val iconSource: String?, val fillMode: FillMode = FillMode.LOGIN, ) /** * 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. */ /** * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits * pass through unchanged — a 7-digit code is rarely grouped, and stripping * letters to chunk the digits would mutilate the code. The autofilled value * stays the raw code so the form receives what its regex expects. */ internal fun chunkOtpForDisplay(code: String): String { if (code.length != 6 || !code.all { it.isDigit() }) return code return code.chunked(3).joinToString(" ") } /** * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when * the dataset fills ONLY an OTP field (no identity/password) so the chip * surfaces the actual TOTP code as the title and the literal "2FA" in the * subtitle. The entry's favicon/initial on the left already conveys the * entry, so a duplicate-name subtitle invites the user to misread the * digits as their password. Private preview always wins: digits are never * leaked into the chip strip even in this mode. */ fun previewLabels( privatePreview: Boolean, entry: VaultEntry, originDomain: String?, fillMode: FillMode = FillMode.LOGIN, otpCode: String? = null, ): PreviewLabels = when { privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode) fillMode == FillMode.OTP_FILL -> { val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" } // Subtitle reads "2FA" instead of the entry title so the user can // tell a TOTP chip from a password chip at a glance — the entry's // favicon/initial on the left already conveys the entry, so a // duplicate-name subtitle invites the user to misread the digits // as their password. PreviewLabels( title = chunkOtpForDisplay(code), subtitle = "2FA", iconSource = entry.title, fillMode = FillMode.OTP_FILL, ) } else -> PreviewLabels( entry.title, entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "", entry.title, FillMode.LOGIN, ) } 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? { // Generate the TOTP code once and route it to BOTH the chip title (when // the dataset fills ONLY an OTP field) and the fill value (always). // The FillMode.OTP_FILL chip surfaces the actual digits in the // keyboard strip so the user can confirm what they're about to type. val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) } val fillingOnlyOtp = parsed.identityId == null && parsed.passwordId == null && parsed.otpId != null && otpCode != null val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN val labels = previewLabels( privatePreview, entry, parsed.origin?.let(::extractDomain), fillMode = fillMode, otpCode = if (fillingOnlyOtp) otpCode else null, ) 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) // otpCode was computed once at the top of datasetFor so the chip can // display it; just route it into the fill here. 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() } /** * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site" * picker chip. The inline chip carries a [PendingIntent] that opens the * picker activity directly on tap (system menu row uses [setAuthentication] * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect). * * Without an attached [InlinePresentation] the dataset would render only * in the drop-down menu — the keyboard-row strip would never show the * picker, which was the v2.14.1 regression June hit. */ fun searchPeachDataset( context: Context, parsed: ParsedStructure, inlineSpec: android.widget.inline.InlinePresentationSpec?, unlocked: Boolean, hasMatches: Boolean, ): Dataset { val labels = if (unlocked) { PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null) } else { PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null) } val menu = simpleRow(context, labels.title, labels.subtitle) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed)) } else { null } val builder = Dataset.Builder(menu) (listOfNotNull( parsed.identityId, parsed.passwordId, parsed.otpId, parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId, parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId, ) + parsed.otpDigitBoxIds).forEach { id -> if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(id, null, menu, inline) } else { builder.setValue(id, null, menu) } } builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed)) return builder.build() } /** * The "+ Save to Peach" create chip: tapping the chip launches MainActivity * on the new-entry editor pre-filled with the typed identity and origin. * F3 single-use token ([AutofillIntents.newEntryChipPendingIntent]) gates * the launch — MainActivity is exported. * * Gated to unlocked vaults: a locked vault cannot write a new entry, and * bouncing through MainActivity's unlock flow from an autofill chip is a * worse experience than waiting for the picker to unlock in-process. */ fun createNewEntryDataset( context: Context, parsed: ParsedStructure, inlineSpec: android.widget.inline.InlinePresentationSpec?, unlocked: Boolean, origin: String?, ): Dataset? { if (!unlocked) return null val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null) val menu = simpleRow(context, labels.title, labels.subtitle) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin)) } else { null } val builder = Dataset.Builder(menu) // Drop a placeholder for every fillable id so the dataset is well-formed // even though no real value is provided — the chip's PendingIntent is the // user-facing affordance, the dataset just makes the chip eligible. (listOfNotNull( parsed.identityId, parsed.passwordId, parsed.otpId, parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId, parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId, ) + parsed.otpDigitBoxIds).forEach { id -> if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(id, null, menu, inline) } else { builder.setValue(id, null, menu) } } return builder.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, chipPending: PendingIntent = AutofillIntents.noopChipIntent(context), ): InlinePresentation? = try { val style = spec.style if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) { null } else { val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) } ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark) val slice = InlineSuggestionUi.newContentBuilder(chipPending) .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 } }