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 android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Icon import android.util.LruCache 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 the already-resolved origin. The service verifies web * caller identity before populating web domains. Native callers match on * their verified app-link domains plus an explicit package-name * association — never on their display label, which any app can choose * freely (PAI-2026-001). Blank origins match nothing. */ fun matchEntries(entries: List, parsed: ParsedStructure): MatchResult { val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits val byPackage = nativePackageMatches(entries, parsed) return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList()) } /** * An entry the user associated with this exact package — the save paths * store the calling package as the entry's url. Exact match, not domain * matching: two packages can share a registrable domain under the public * suffix list (com.a.example.app and com.b.example.app both reduce to * example.app), so anything weaker would re-open the confusion the label * match had. */ private fun nativePackageMatches(entries: List, parsed: ParsedStructure): List { val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList() if (parsed.webDomain != null) return emptyList() return entries.filter { entry -> entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName } } } 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, val endIcon: Icon? = null, ) /** * 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, secondsRemaining: Int? = 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. When [secondsRemaining] is known we // append the TOTP window countdown (the chip itself is static; // a fresh chip with a fresh code is rebuilt on every focus-change // FillRequest, so the user only needs to tap off and back into // the field to get the next code). val subtitle = when { secondsRemaining == null -> "2FA" secondsRemaining <= 0 -> "2FA · expired" else -> "2FA · ${secondsRemaining}s" } val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) } PreviewLabels( title = chunkOtpForDisplay(code), subtitle = subtitle, iconSource = entry.title, fillMode = FillMode.OTP_FILL, endIcon = endIcon, ) } 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 otp = entry.totp?.let { TOTPGenerator.generate(it) } val otpCode = otp?.code 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, secondsRemaining = otp?.remaining, ) 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) // OTP fill on a multi-field form is handled by [otpDatasetFor] -- a // separate dataset with its own chip on the 2FA field. The LOGIN chip // intentionally does NOT fill otp here, so the user can verify the // code before it lands: "tap github fills email and password, then // automatically surfaces the next chip with 2FA code" (continuity // 2026-08-05). On an OTP-only form (fillingOnlyOtp) we keep filling // from the LOGIN path -- there's no separate chip to defer to. if (fillingOnlyOtp) 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 } /** * A second inline dataset for the 2FA chip: fills ONLY the detected OTP * field with a freshly generated TOTP code and renders its chip in * [FillMode.OTP_FILL] (title = chunked code, subtitle = "2FA"). The user * wanted the LOGIN chip and the 2FA chip as separate affordances: * "tap 'github' it fills email and password, then automatically surfaces * the next chip with 2FA code" (continuity 2026-08-05). The chip lands on * the spec whose autofillId matches [parsed.otpId]; if no spec matches * (the framework only offered a single spec on a focused 2FA field, say), * the dataset still surfaces in the system menu. */ fun otpDatasetFor( context: Context, parsed: ParsedStructure, entry: VaultEntry, inlineSpec: android.widget.inline.InlinePresentationSpec?, privatePreview: Boolean, ): Dataset? { val otpId = parsed.otpId ?: return null val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null val otpCode = otp.code val labels = previewLabels( privatePreview = privatePreview, entry = entry, originDomain = parsed.origin?.let(::extractDomain), fillMode = FillMode.OTP_FILL, otpCode = otpCode, secondsRemaining = otp.remaining, ) 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() val av = AutofillValue.forText(otpCode) if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(otpId, av, menu, inline) } else { builder.setValue(otpId, av, menu) } return builder.build() } /** * 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() } // 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) .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) } .build() .slice InlinePresentation(slice, spec, false) } } catch (_: Throwable) { // A non-conforming IME spec must never crash the fill — degrade to menu. null } /** * Small colored dot rendered as the inline chip's end icon when the 2FA * chip is in [FillMode.OTP_FILL] with a known [secondsRemaining]. * * Colors match the urgency: green (>= 10s), amber (5..9s), red (<= 4s * or already expired). The chip itself is a static RemoteViews render * — once built, no live animation runs; a fresh chip with a fresh * code is returned on the next focus-change FillRequest, so the * user only needs to tap off the keyboard and back into the 2FA * field to get the next code. Cached by color so per-fill alloc is * constant. */ private object OtpCountdownDot { private const val SIZE_PX = 32 private const val GREEN = 0xFF6FB07F.toInt() private const val AMBER = 0xFFE0A857.toInt() private const val RED = 0xFFD8534B.toInt() private val cache = LruCache(8) fun icon(secondsRemaining: Int): Icon? { val color = when { secondsRemaining <= 4 -> RED secondsRemaining <= 9 -> AMBER else -> GREEN } cache.get(color)?.let { return it } // Bitmap creation is a no-op on the JVM test runtime (the // android.jar stubs return null for createBitmap). Fail-soft // there — the chip still gets the seconds-left text; the // colored dot only matters on a real device where the dot // is also the glanceable expiry signal the user asked for. val icon = runCatching { val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888) val canvas = Canvas(bm) val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color } canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint) Icon.createWithBitmap(bm) }.getOrNull() ?: return null cache.put(color, icon) return icon } } }