AutofillResponseBuilder.kt

21.7 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 an origin with no caller-trust gate (origin verification50     * removed, queue #3). Web origins match by the page domain; native apps match51     * by their app-link domains or display title. Blank origins match nothing.52     */53    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {54        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()55        val byDomain = matchDomains(entries, domains).hits56        val appTitle = parsed.appTitle?.trim()57        val byTitle = if (appTitle.isNullOrBlank()) {58            emptyList()59        } else {60            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }61        }62        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())63    }6465    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =66        MatchResult(67            hits = entries.filter { entry ->68                domains.any { domain -> entry.matchesOriginDomain(domain) }69            },70            rest = emptyList(),71        )7273    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */74    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =75        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7677    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =78        sequenceOf(url)79            .plus(additionalUrls.orEmpty().asSequence())80            .filterNotNull()81            .mapNotNull(::extractDomain)82            .any { it == originDomain }8384    /**85     * What a suggestion row or keyboard chip shows about an entry.86     * [iconSource] seeds the chip monogram; null keeps the Peach mark87     * (always the case in privacy mode — initials would leak titles too).88     */89    enum class FillMode { LOGIN, OTP_FILL }9091    data class PreviewLabels(92        val title: String,93        val subtitle: String,94        val iconSource: String?,95        val fillMode: FillMode = FillMode.LOGIN,96        val endIcon: Icon? = null,97    )9899    /**100     * Privacy previews: with [privatePreview] the rows and chips render only101     * the Peach brand and the caller's domain — no entry titles or usernames102     * in the IME's suggestion strip. The values still fill on tap.103     */104    /**105     * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric106     * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits107     * pass through unchanged — a 7-digit code is rarely grouped, and stripping108     * letters to chunk the digits would mutilate the code. The autofilled value109     * stays the raw code so the form receives what its regex expects.110     */111    internal fun chunkOtpForDisplay(code: String): String {112        if (code.length != 6 || !code.all { it.isDigit() }) return code113        return code.chunked(3).joinToString(" ")114    }115116    /**117     * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when118     * the dataset fills ONLY an OTP field (no identity/password) so the chip119     * surfaces the actual TOTP code as the title and the literal "2FA" in the120     * subtitle. The entry's favicon/initial on the left already conveys the121     * entry, so a duplicate-name subtitle invites the user to misread the122     * digits as their password. Private preview always wins: digits are never123     * leaked into the chip strip even in this mode.124     */125    fun previewLabels(126        privatePreview: Boolean,127        entry: VaultEntry,128        originDomain: String?,129        fillMode: FillMode = FillMode.LOGIN,130        otpCode: String? = null,131        secondsRemaining: Int? = null,132    ): PreviewLabels = when {133        privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)134        fillMode == FillMode.OTP_FILL -> {135            val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }136            // Subtitle reads "2FA" instead of the entry title so the user can137            // tell a TOTP chip from a password chip at a glance — the entry's138            // favicon/initial on the left already conveys the entry, so a139            // duplicate-name subtitle invites the user to misread the digits140            // as their password. When [secondsRemaining] is known we141            // append the TOTP window countdown (the chip itself is static;142            // a fresh chip with a fresh code is rebuilt on every focus-change143            // FillRequest, so the user only needs to tap off and back into144            // the field to get the next code).145            val subtitle = when {146                secondsRemaining == null -> "2FA"147                secondsRemaining <= 0 -> "2FA · expired"148                else -> "2FA · ${secondsRemaining}s"149            }150            val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }151            PreviewLabels(152                title = chunkOtpForDisplay(code),153                subtitle = subtitle,154                iconSource = entry.title,155                fillMode = FillMode.OTP_FILL,156                endIcon = endIcon,157            )158        }159        else -> PreviewLabels(160            entry.title,161            entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",162            entry.title,163            FillMode.LOGIN,164        )165    }166167    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =168        RemoteViews(context.packageName, R.layout.autofill_row).apply {169            setTextViewText(R.id.autofill_row_title, labels.title)170            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)171        }172173    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =174        RemoteViews(context.packageName, R.layout.autofill_row).apply {175            setTextViewText(R.id.autofill_row_title, title)176            setTextViewText(R.id.autofill_row_subtitle, subtitle)177        }178179    /**180     * Build a dataset filling the parsed fields from [entry]. Attaches the inline181     * chip when the IME offered a spec (API 30+); otherwise menu-only.182     */183    fun datasetFor(184        context: Context,185        parsed: ParsedStructure,186        entry: VaultEntry,187        inlineSpec: android.widget.inline.InlinePresentationSpec?,188        privatePreview: Boolean,189    ): Dataset? {190        // Generate the TOTP code once and route it to BOTH the chip title (when191        // the dataset fills ONLY an OTP field) and the fill value (always).192        // The FillMode.OTP_FILL chip surfaces the actual digits in the193        // keyboard strip so the user can confirm what they're about to type.194        val otp = entry.totp?.let { TOTPGenerator.generate(it) }195        val otpCode = otp?.code196        val fillingOnlyOtp = parsed.identityId == null &&197            parsed.passwordId == null &&198            parsed.otpId != null &&199            otpCode != null200        val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN201        val labels = previewLabels(202            privatePreview,203            entry,204            parsed.origin?.let(::extractDomain),205            fillMode = fillMode,206            otpCode = if (fillingOnlyOtp) otpCode else null,207            secondsRemaining = otp?.remaining,208        )209        val menu = rowFor(context, labels)210        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {211            buildInline(context, labels, inlineSpec)212        } else {213            null214        }215216        val builder = Dataset.Builder()217        var filledAny = false218        fun put(id: AutofillId?, value: String?) {219            if (id == null || value.isNullOrEmpty()) return220            val av = AutofillValue.forText(value)221            if (inline != null && Build.VERSION.SDK_INT >= 30) {222                builder.setValue(id, av, menu, inline)223            } else {224                builder.setValue(id, av, menu)225            }226            filledAny = true227        }228        put(parsed.identityId, entry.username ?: entry.email)229        put(parsed.passwordId, entry.password)230        // OTP fill on a multi-field form is handled by [otpDatasetFor] -- a231        // separate dataset with its own chip on the 2FA field. The LOGIN chip232        // intentionally does NOT fill otp here, so the user can verify the233        // code before it lands: "tap github fills email and password, then234        // automatically surfaces the next chip with 2FA code" (continuity235        // 2026-08-05). On an OTP-only form (fillingOnlyOtp) we keep filling236        // from the LOGIN path -- there's no separate chip to defer to.237        if (fillingOnlyOtp) put(parsed.otpId, otpCode)238        // A6: split single-character OTP boxes — one character per box239        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.240        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {241            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->242                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)243            }244            filledAny = true245        }246247        return if (filledAny) builder.build() else null248    }249250    /**251     * A second inline dataset for the 2FA chip: fills ONLY the detected OTP252     * field with a freshly generated TOTP code and renders its chip in253     * [FillMode.OTP_FILL] (title = chunked code, subtitle = "2FA"). The user254     * wanted the LOGIN chip and the 2FA chip as separate affordances:255     * "tap 'github' it fills email and password, then automatically surfaces256     * the next chip with 2FA code" (continuity 2026-08-05). The chip lands on257     * the spec whose autofillId matches [parsed.otpId]; if no spec matches258     * (the framework only offered a single spec on a focused 2FA field, say),259     * the dataset still surfaces in the system menu.260     */261    fun otpDatasetFor(262        context: Context,263        parsed: ParsedStructure,264        entry: VaultEntry,265        inlineSpec: android.widget.inline.InlinePresentationSpec?,266        privatePreview: Boolean,267    ): Dataset? {268        val otpId = parsed.otpId ?: return null269        val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null270        val otpCode = otp.code271        val labels = previewLabels(272            privatePreview = privatePreview,273            entry = entry,274            originDomain = parsed.origin?.let(::extractDomain),275            fillMode = FillMode.OTP_FILL,276            otpCode = otpCode,277            secondsRemaining = otp.remaining,278        )279        val menu = rowFor(context, labels)280        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {281            buildInline(context, labels, inlineSpec)282        } else {283            null284        }285        val builder = Dataset.Builder()286        val av = AutofillValue.forText(otpCode)287        if (inline != null && Build.VERSION.SDK_INT >= 30) {288            builder.setValue(otpId, av, menu, inline)289        } else {290            builder.setValue(otpId, av, menu)291        }292        return builder.build()293    }294295    /**296     * Build a dataset filling detected card fields from a card [entry].297     * Cards are not domain-matched — any detected card form offers all cards.298     */299    fun cardDatasetFor(300        context: Context,301        parsed: ParsedStructure,302        entry: VaultEntry,303        inlineSpec: android.widget.inline.InlinePresentationSpec?,304        privatePreview: Boolean,305    ): Dataset? {306        if (!entry.isCard()) return null307        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))308        val menu = rowFor(context, labels)309        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {310            buildInline(context, labels, inlineSpec)311        } else {312            null313        }314315        val builder = Dataset.Builder()316        var filledAny = false317        fun put(id: AutofillId?, value: String?) {318            if (id == null || value.isNullOrEmpty()) return319            val av = AutofillValue.forText(value)320            if (inline != null && Build.VERSION.SDK_INT >= 30) {321                builder.setValue(id, av, menu, inline)322            } else {323                builder.setValue(id, av, menu)324            }325            filledAny = true326        }327328        val digits = entry.cardNumber?.filter { it.isDigit() }329        put(parsed.cardNumberId, digits)330        put(parsed.cardExpMonthId, entry.expMonth)331        put(parsed.cardExpYearId, entry.expYear)332        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {333            val yy = entry.expYear.takeLast(2)334            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")335        }336        put(parsed.cardCvcId, entry.cvc)337        put(parsed.cardNameId, entry.cardholderName)338339        return if (filledAny) builder.build() else null340    }341342    /**343     * A "generate strong password" dataset for signup / no-match forms. Fills the344     * detected password field with a freshly generated password sized to the345     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends346     * up in the password field, so the generated value is offered for save like347     * any other fill. Menu-only (no inline chip) — there is no vault entry to348     * render. Parity for the browser's inline generator on create forms.349     */350    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {351        val passwordId = parsed.passwordId ?: return null352        val policy = parsed.passwordPolicy353        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH354        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))355        val note = policy?.constraintNote(length).orEmpty()356        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })357        return Dataset.Builder()358            .setValue(passwordId, AutofillValue.forText(password), menu)359            .build()360    }361362    /**363     * A4 identity helper: a dataset filling ONLY the signup identity field.364     * Email identities prefer the email field, usernames the username field;365     * each falls back to the other when its preferred field is absent.366     */367    fun identityDatasetFor(368        context: Context,369        parsed: ParsedStructure,370        identity: SavedIdentity,371    ): Dataset? {372        val targetId = when (identity.type) {373            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId374            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId375        } ?: return null376        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"377        val menu = simpleRow(context, identity.value, subtitle)378        return Dataset.Builder()379            .setValue(targetId, AutofillValue.forText(identity.value), menu)380            .build()381    }382383    /**384     * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site"385     * picker chip. The inline chip carries a [PendingIntent] that opens the386     * picker activity directly on tap (system menu row uses [setAuthentication]387     * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect).388     *389     * Without an attached [InlinePresentation] the dataset would render only390     * in the drop-down menu — the keyboard-row strip would never show the391     * picker, which was the v2.14.1 regression June hit.392     */393    fun searchPeachDataset(394        context: Context,395        parsed: ParsedStructure,396        inlineSpec: android.widget.inline.InlinePresentationSpec?,397        unlocked: Boolean,398        hasMatches: Boolean,399    ): Dataset {400        val labels = if (unlocked) {401            PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)402        } else {403            PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)404        }405        val menu = simpleRow(context, labels.title, labels.subtitle)406        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {407            buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))408        } else {409            null410        }411        val builder = Dataset.Builder(menu)412        (listOfNotNull(413            parsed.identityId, parsed.passwordId, parsed.otpId,414            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,415            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,416        ) + parsed.otpDigitBoxIds).forEach { id ->417            if (inline != null && Build.VERSION.SDK_INT >= 30) {418                builder.setValue(id, null, menu, inline)419            } else {420                builder.setValue(id, null, menu)421            }422        }423        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))424        return builder.build()425    }426427    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but428    // reading the slice to build an InlinePresentation is the documented usage.429    @SuppressLint("RestrictedApi")430    @RequiresApi(30)431    private fun buildInline(432        context: Context,433        labels: PreviewLabels,434        spec: android.widget.inline.InlinePresentationSpec,435        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),436    ): InlinePresentation? = try {437        val style = spec.style438        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {439            null440        } else {441            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }442                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)443            val slice = InlineSuggestionUi.newContentBuilder(chipPending)444                .setTitle(labels.title)445                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }446                .setStartIcon(startIcon)447                .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }448                .build()449                .slice450            InlinePresentation(slice, spec, false)451        }452    } catch (_: Throwable) {453        // A non-conforming IME spec must never crash the fill — degrade to menu.454        null455    }456457    /**458     * Small colored dot rendered as the inline chip's end icon when the 2FA459     * chip is in [FillMode.OTP_FILL] with a known [secondsRemaining].460     *461     * Colors match the urgency: green (>= 10s), amber (5..9s), red (<= 4s462     * or already expired). The chip itself is a static RemoteViews render463     * — once built, no live animation runs; a fresh chip with a fresh464     * code is returned on the next focus-change FillRequest, so the465     * user only needs to tap off the keyboard and back into the 2FA466     * field to get the next code. Cached by color so per-fill alloc is467     * constant.468     */469    private object OtpCountdownDot {470        private const val SIZE_PX = 32471        private const val GREEN = 0xFF6FB07F.toInt()472        private const val AMBER = 0xFFE0A857.toInt()473        private const val RED = 0xFFD8534B.toInt()474        private val cache = LruCache<Int, Icon>(8)475476        fun icon(secondsRemaining: Int): Icon? {477            val color = when {478                secondsRemaining <= 4 -> RED479                secondsRemaining <= 9 -> AMBER480                else -> GREEN481            }482            cache.get(color)?.let { return it }483            // Bitmap creation is a no-op on the JVM test runtime (the484            // android.jar stubs return null for createBitmap). Fail-soft485            // there — the chip still gets the seconds-left text; the486            // colored dot only matters on a real device where the dot487            // is also the glanceable expiry signal the user asked for.488            val icon = runCatching {489                val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)490                val canvas = Canvas(bm)491                val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }492                canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)493                Icon.createWithBitmap(bm)494            }.getOrNull() ?: return null495            cache.put(color, icon)496            return icon497        }498    }499}