AutofillResponseBuilder.kt

22.8 KB · raw

1package com.peachpasswords.autofill23import android.annotation.SuppressLint4import android.app.PendingIntent5import android.content.Context6import android.os.Build7import android.service.autofill.Dataset8import android.service.autofill.InlinePresentation9import android.view.autofill.AutofillId10import android.view.autofill.AutofillValue11import android.widget.RemoteViews12import androidx.annotation.RequiresApi13import android.graphics.Bitmap14import android.graphics.Canvas15import android.graphics.Paint16import android.graphics.drawable.Icon17import android.util.LruCache18import androidx.autofill.inline.v1.InlineSuggestionUi19import androidx.autofill.inline.UiVersions20import com.peachpasswords.R21import com.peachpasswords.shared.extractDomain22import com.peachpasswords.vault.PasswordGenerator23import com.peachpasswords.vault.TOTPGenerator24import com.peachpasswords.vault.VaultEntry25import com.peachpasswords.vault.isCard2627/**28 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu29 * RemoteViews row, the inline keyboard chip, and the actual field values.30 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.31 */32object AutofillResponseBuilder {3334    /**35     * Registrable-domain matching. Missing, malformed, local, and IP origins36     * return no entries (there is nothing to rank against).37     */38    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {39        val all: List<VaultEntry> get() = hits + rest40    }4142    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {43        val originDomain = origin?.let(::extractDomain)44            ?: return MatchResult(emptyList(), emptyList())45        return matchDomains(entries, setOf(originDomain))46    }4748    /**49     * Rank entries for the already-resolved origin. The service verifies web50     * caller identity before populating web domains. Native callers match on51     * their verified app-link domains plus an explicit package-name52     * association — never on their display label, which any app can choose53     * freely (PAI-2026-001). Blank origins match nothing.54     */55    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {56        val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits57        val byPackage = nativePackageMatches(entries, parsed)58        return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList())59    }6061    /**62     * An entry the user associated with this exact package — the save paths63     * store the calling package as the entry's url. Exact match, not domain64     * matching: two packages can share a registrable domain under the public65     * suffix list (com.a.example.app and com.b.example.app both reduce to66     * example.app), so anything weaker would re-open the confusion the label67     * match had.68     */69    private fun nativePackageMatches(entries: List<VaultEntry>, parsed: ParsedStructure): List<VaultEntry> {70        val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList()71        if (parsed.webDomain != null) return emptyList()72        return entries.filter { entry ->73            entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName }74        }75    }7677    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =78        MatchResult(79            hits = entries.filter { entry ->80                domains.any { domain -> entry.matchesOriginDomain(domain) }81            },82            rest = emptyList(),83        )8485    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */86    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =87        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }8889    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =90        sequenceOf(url)91            .plus(additionalUrls.orEmpty().asSequence())92            .filterNotNull()93            .mapNotNull(::extractDomain)94            .any { it == originDomain }9596    /**97     * What a suggestion row or keyboard chip shows about an entry.98     * [iconSource] seeds the chip monogram; null keeps the Peach mark99     * (always the case in privacy mode — initials would leak titles too).100     */101    enum class FillMode { LOGIN, OTP_FILL }102103    data class PreviewLabels(104        val title: String,105        val subtitle: String,106        val iconSource: String?,107        val fillMode: FillMode = FillMode.LOGIN,108        val endIcon: Icon? = null,109    )110111    /**112     * Privacy previews: with [privatePreview] the rows and chips render only113     * the Peach brand and the caller's domain — no entry titles or usernames114     * in the IME's suggestion strip. The values still fill on tap.115     */116    /**117     * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric118     * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits119     * pass through unchanged — a 7-digit code is rarely grouped, and stripping120     * letters to chunk the digits would mutilate the code. The autofilled value121     * stays the raw code so the form receives what its regex expects.122     */123    internal fun chunkOtpForDisplay(code: String): String {124        if (code.length != 6 || !code.all { it.isDigit() }) return code125        return code.chunked(3).joinToString(" ")126    }127128    /**129     * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when130     * the dataset fills ONLY an OTP field (no identity/password) so the chip131     * surfaces the actual TOTP code as the title and the literal "2FA" in the132     * subtitle. The entry's favicon/initial on the left already conveys the133     * entry, so a duplicate-name subtitle invites the user to misread the134     * digits as their password. Private preview always wins: digits are never135     * leaked into the chip strip even in this mode.136     */137    fun previewLabels(138        privatePreview: Boolean,139        entry: VaultEntry,140        originDomain: String?,141        fillMode: FillMode = FillMode.LOGIN,142        otpCode: String? = null,143        secondsRemaining: Int? = null,144    ): PreviewLabels = when {145        privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)146        fillMode == FillMode.OTP_FILL -> {147            val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }148            // Subtitle reads "2FA" instead of the entry title so the user can149            // tell a TOTP chip from a password chip at a glance — the entry's150            // favicon/initial on the left already conveys the entry, so a151            // duplicate-name subtitle invites the user to misread the digits152            // as their password. When [secondsRemaining] is known we153            // append the TOTP window countdown (the chip itself is static;154            // a fresh chip with a fresh code is rebuilt on every focus-change155            // FillRequest, so the user only needs to tap off and back into156            // the field to get the next code).157            val subtitle = when {158                secondsRemaining == null -> "2FA"159                secondsRemaining <= 0 -> "2FA · expired"160                else -> "2FA · ${secondsRemaining}s"161            }162            val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }163            PreviewLabels(164                title = chunkOtpForDisplay(code),165                subtitle = subtitle,166                iconSource = entry.title,167                fillMode = FillMode.OTP_FILL,168                endIcon = endIcon,169            )170        }171        else -> PreviewLabels(172            entry.title,173            entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",174            entry.title,175            FillMode.LOGIN,176        )177    }178179    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =180        RemoteViews(context.packageName, R.layout.autofill_row).apply {181            setTextViewText(R.id.autofill_row_title, labels.title)182            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)183        }184185    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =186        RemoteViews(context.packageName, R.layout.autofill_row).apply {187            setTextViewText(R.id.autofill_row_title, title)188            setTextViewText(R.id.autofill_row_subtitle, subtitle)189        }190191    /**192     * Build a dataset filling the parsed fields from [entry]. Attaches the inline193     * chip when the IME offered a spec (API 30+); otherwise menu-only.194     */195    fun datasetFor(196        context: Context,197        parsed: ParsedStructure,198        entry: VaultEntry,199        inlineSpec: android.widget.inline.InlinePresentationSpec?,200        privatePreview: Boolean,201    ): Dataset? {202        // Generate the TOTP code once and route it to BOTH the chip title (when203        // the dataset fills ONLY an OTP field) and the fill value (always).204        // The FillMode.OTP_FILL chip surfaces the actual digits in the205        // keyboard strip so the user can confirm what they're about to type.206        val otp = entry.totp?.let { TOTPGenerator.generate(it) }207        val otpCode = otp?.code208        val fillingOnlyOtp = parsed.identityId == null &&209            parsed.passwordId == null &&210            parsed.otpId != null &&211            otpCode != null212        val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN213        val labels = previewLabels(214            privatePreview,215            entry,216            parsed.origin?.let(::extractDomain),217            fillMode = fillMode,218            otpCode = if (fillingOnlyOtp) otpCode else null,219            secondsRemaining = otp?.remaining,220        )221        val menu = rowFor(context, labels)222        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {223            buildInline(context, labels, inlineSpec)224        } else {225            null226        }227228        val builder = Dataset.Builder()229        var filledAny = false230        fun put(id: AutofillId?, value: String?) {231            if (id == null || value.isNullOrEmpty()) return232            val av = AutofillValue.forText(value)233            if (inline != null && Build.VERSION.SDK_INT >= 30) {234                builder.setValue(id, av, menu, inline)235            } else {236                builder.setValue(id, av, menu)237            }238            filledAny = true239        }240        put(parsed.identityId, entry.username ?: entry.email)241        put(parsed.passwordId, entry.password)242        // OTP fill on a multi-field form is handled by [otpDatasetFor] -- a243        // separate dataset with its own chip on the 2FA field. The LOGIN chip244        // intentionally does NOT fill otp here, so the user can verify the245        // code before it lands: "tap github fills email and password, then246        // automatically surfaces the next chip with 2FA code" (continuity247        // 2026-08-05). On an OTP-only form (fillingOnlyOtp) we keep filling248        // from the LOGIN path -- there's no separate chip to defer to.249        if (fillingOnlyOtp) put(parsed.otpId, otpCode)250        // A6: split single-character OTP boxes — one character per box251        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.252        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {253            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->254                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)255            }256            filledAny = true257        }258259        return if (filledAny) builder.build() else null260    }261262    /**263     * A second inline dataset for the 2FA chip: fills ONLY the detected OTP264     * field with a freshly generated TOTP code and renders its chip in265     * [FillMode.OTP_FILL] (title = chunked code, subtitle = "2FA"). The user266     * wanted the LOGIN chip and the 2FA chip as separate affordances:267     * "tap 'github' it fills email and password, then automatically surfaces268     * the next chip with 2FA code" (continuity 2026-08-05). The chip lands on269     * the spec whose autofillId matches [parsed.otpId]; if no spec matches270     * (the framework only offered a single spec on a focused 2FA field, say),271     * the dataset still surfaces in the system menu.272     */273    fun otpDatasetFor(274        context: Context,275        parsed: ParsedStructure,276        entry: VaultEntry,277        inlineSpec: android.widget.inline.InlinePresentationSpec?,278        privatePreview: Boolean,279    ): Dataset? {280        val otpId = parsed.otpId ?: return null281        val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null282        val otpCode = otp.code283        val labels = previewLabels(284            privatePreview = privatePreview,285            entry = entry,286            originDomain = parsed.origin?.let(::extractDomain),287            fillMode = FillMode.OTP_FILL,288            otpCode = otpCode,289            secondsRemaining = otp.remaining,290        )291        val menu = rowFor(context, labels)292        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {293            buildInline(context, labels, inlineSpec)294        } else {295            null296        }297        val builder = Dataset.Builder()298        val av = AutofillValue.forText(otpCode)299        if (inline != null && Build.VERSION.SDK_INT >= 30) {300            builder.setValue(otpId, av, menu, inline)301        } else {302            builder.setValue(otpId, av, menu)303        }304        return builder.build()305    }306307    /**308     * Build a dataset filling detected card fields from a card [entry].309     * Cards are not domain-matched — any detected card form offers all cards.310     */311    fun cardDatasetFor(312        context: Context,313        parsed: ParsedStructure,314        entry: VaultEntry,315        inlineSpec: android.widget.inline.InlinePresentationSpec?,316        privatePreview: Boolean,317    ): Dataset? {318        if (!entry.isCard()) return null319        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))320        val menu = rowFor(context, labels)321        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {322            buildInline(context, labels, inlineSpec)323        } else {324            null325        }326327        val builder = Dataset.Builder()328        var filledAny = false329        fun put(id: AutofillId?, value: String?) {330            if (id == null || value.isNullOrEmpty()) return331            val av = AutofillValue.forText(value)332            if (inline != null && Build.VERSION.SDK_INT >= 30) {333                builder.setValue(id, av, menu, inline)334            } else {335                builder.setValue(id, av, menu)336            }337            filledAny = true338        }339340        val digits = entry.cardNumber?.filter { it.isDigit() }341        put(parsed.cardNumberId, digits)342        put(parsed.cardExpMonthId, entry.expMonth)343        put(parsed.cardExpYearId, entry.expYear)344        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {345            val yy = entry.expYear.takeLast(2)346            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")347        }348        put(parsed.cardCvcId, entry.cvc)349        put(parsed.cardNameId, entry.cardholderName)350351        return if (filledAny) builder.build() else null352    }353354    /**355     * A "generate strong password" dataset for signup / no-match forms. Fills the356     * detected password field with a freshly generated password sized to the357     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends358     * up in the password field, so the generated value is offered for save like359     * any other fill. Menu-only (no inline chip) — there is no vault entry to360     * render. Parity for the browser's inline generator on create forms.361     */362    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {363        val passwordId = parsed.passwordId ?: return null364        val policy = parsed.passwordPolicy365        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH366        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))367        val note = policy?.constraintNote(length).orEmpty()368        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })369        return Dataset.Builder()370            .setValue(passwordId, AutofillValue.forText(password), menu)371            .build()372    }373374    /**375     * A4 identity helper: a dataset filling ONLY the signup identity field.376     * Email identities prefer the email field, usernames the username field;377     * each falls back to the other when its preferred field is absent.378     */379    fun identityDatasetFor(380        context: Context,381        parsed: ParsedStructure,382        identity: SavedIdentity,383    ): Dataset? {384        val targetId = when (identity.type) {385            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId386            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId387        } ?: return null388        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"389        val menu = simpleRow(context, identity.value, subtitle)390        return Dataset.Builder()391            .setValue(targetId, AutofillValue.forText(identity.value), menu)392            .build()393    }394395    /**396     * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site"397     * picker chip. The inline chip carries a [PendingIntent] that opens the398     * picker activity directly on tap (system menu row uses [setAuthentication]399     * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect).400     *401     * Without an attached [InlinePresentation] the dataset would render only402     * in the drop-down menu — the keyboard-row strip would never show the403     * picker, which was the v2.14.1 regression June hit.404     */405    fun searchPeachDataset(406        context: Context,407        parsed: ParsedStructure,408        inlineSpec: android.widget.inline.InlinePresentationSpec?,409        unlocked: Boolean,410        hasMatches: Boolean,411    ): Dataset {412        val labels = if (unlocked) {413            PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)414        } else {415            PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)416        }417        val menu = simpleRow(context, labels.title, labels.subtitle)418        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {419            buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))420        } else {421            null422        }423        val builder = Dataset.Builder(menu)424        val fieldIds = listOfNotNull(425            parsed.identityId, parsed.passwordId, parsed.otpId,426            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,427            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,428        ) + parsed.otpDigitBoxIds429        if (inline != null && Build.VERSION.SDK_INT >= 30) {430            // Samsung's inline renderer drops authenticated datasets whose only431            // field values are null. Bind the chip to one credential field with432            // an empty value; authentication still supplies the real dataset.433            fieldIds.firstOrNull()?.let { id ->434                builder.setValue(id, AutofillValue.forText(""), menu, inline)435            }436            fieldIds.drop(1).forEach { id -> builder.setValue(id, null, menu) }437        } else {438            fieldIds.forEach { id -> builder.setValue(id, null, menu) }439        }440        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))441        return builder.build()442    }443444    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but445    // reading the slice to build an InlinePresentation is the documented usage.446    @SuppressLint("RestrictedApi")447    @RequiresApi(30)448    private fun buildInline(449        context: Context,450        labels: PreviewLabels,451        spec: android.widget.inline.InlinePresentationSpec,452        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),453    ): InlinePresentation? = try {454        val style = spec.style455        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {456            null457        } else {458            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }459                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)460            val slice = InlineSuggestionUi.newContentBuilder(chipPending)461                .setTitle(labels.title)462                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }463                .setStartIcon(startIcon)464                .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }465                .build()466                .slice467            InlinePresentation(slice, spec, false)468        }469    } catch (_: Throwable) {470        // A non-conforming IME spec must never crash the fill — degrade to menu.471        null472    }473474    /**475     * Small colored dot rendered as the inline chip's end icon when the 2FA476     * chip is in [FillMode.OTP_FILL] with a known [secondsRemaining].477     *478     * Colors match the urgency: green (>= 10s), amber (5..9s), red (<= 4s479     * or already expired). The chip itself is a static RemoteViews render480     * — once built, no live animation runs; a fresh chip with a fresh481     * code is returned on the next focus-change FillRequest, so the482     * user only needs to tap off the keyboard and back into the 2FA483     * field to get the next code. Cached by color so per-fill alloc is484     * constant.485     */486    private object OtpCountdownDot {487        private const val SIZE_PX = 32488        private const val GREEN = 0xFF6FB07F.toInt()489        private const val AMBER = 0xFFE0A857.toInt()490        private const val RED = 0xFFD8534B.toInt()491        private val cache = LruCache<Int, Icon>(8)492493        fun icon(secondsRemaining: Int): Icon? {494            val color = when {495                secondsRemaining <= 4 -> RED496                secondsRemaining <= 9 -> AMBER497                else -> GREEN498            }499            cache.get(color)?.let { return it }500            // Bitmap creation is a no-op on the JVM test runtime (the501            // android.jar stubs return null for createBitmap). Fail-soft502            // there — the chip still gets the seconds-left text; the503            // colored dot only matters on a real device where the dot504            // is also the glanceable expiry signal the user asked for.505            val icon = runCatching {506                val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)507                val canvas = Canvas(bm)508                val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }509                canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)510                Icon.createWithBitmap(bm)511            }.getOrNull() ?: return null512            cache.put(color, icon)513            return icon514        }515    }516}