AutofillResponseBuilder.kt

18.4 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 androidx.autofill.inline.v1.InlineSuggestionUi14import androidx.autofill.inline.UiVersions15import com.peachpasswords.R16import com.peachpasswords.shared.extractDomain17import com.peachpasswords.vault.PasswordGenerator18import com.peachpasswords.vault.TOTPGenerator19import com.peachpasswords.vault.VaultEntry20import com.peachpasswords.vault.isCard2122/**23 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu24 * RemoteViews row, the inline keyboard chip, and the actual field values.25 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.26 */27object AutofillResponseBuilder {2829    /**30     * Registrable-domain matching. Missing, malformed, local, and IP origins31     * return no entries (there is nothing to rank against).32     */33    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {34        val all: List<VaultEntry> get() = hits + rest35    }3637    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {38        val originDomain = origin?.let(::extractDomain)39            ?: return MatchResult(emptyList(), emptyList())40        return matchDomains(entries, setOf(originDomain))41    }4243    /**44     * Rank entries for an origin with no caller-trust gate (origin verification45     * removed, queue #3). Web origins match by the page domain; native apps match46     * by their app-link domains or display title. Blank origins match nothing.47     */48    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {49        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()50        val byDomain = matchDomains(entries, domains).hits51        val appTitle = parsed.appTitle?.trim()52        val byTitle = if (appTitle.isNullOrBlank()) {53            emptyList()54        } else {55            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }56        }57        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())58    }5960    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =61        MatchResult(62            hits = entries.filter { entry ->63                domains.any { domain -> entry.matchesOriginDomain(domain) }64            },65            rest = emptyList(),66        )6768    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */69    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =70        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7172    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =73        sequenceOf(url)74            .plus(additionalUrls.orEmpty().asSequence())75            .filterNotNull()76            .mapNotNull(::extractDomain)77            .any { it == originDomain }7879    /**80     * What a suggestion row or keyboard chip shows about an entry.81     * [iconSource] seeds the chip monogram; null keeps the Peach mark82     * (always the case in privacy mode — initials would leak titles too).83     */84    enum class FillMode { LOGIN, OTP_FILL }8586    data class PreviewLabels(87        val title: String,88        val subtitle: String,89        val iconSource: String?,90        val fillMode: FillMode = FillMode.LOGIN,91    )9293    /**94     * Privacy previews: with [privatePreview] the rows and chips render only95     * the Peach brand and the caller's domain — no entry titles or usernames96     * in the IME's suggestion strip. The values still fill on tap.97     */98    /**99     * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric100     * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits101     * pass through unchanged — a 7-digit code is rarely grouped, and stripping102     * letters to chunk the digits would mutilate the code. The autofilled value103     * stays the raw code so the form receives what its regex expects.104     */105    internal fun chunkOtpForDisplay(code: String): String {106        if (code.length != 6 || !code.all { it.isDigit() }) return code107        return code.chunked(3).joinToString(" ")108    }109110    /**111     * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when112     * the dataset fills ONLY an OTP field (no identity/password) so the chip113     * surfaces the actual TOTP code as the title and the literal "2FA" in the114     * subtitle. The entry's favicon/initial on the left already conveys the115     * entry, so a duplicate-name subtitle invites the user to misread the116     * digits as their password. Private preview always wins: digits are never117     * leaked into the chip strip even in this mode.118     */119    fun previewLabels(120        privatePreview: Boolean,121        entry: VaultEntry,122        originDomain: String?,123        fillMode: FillMode = FillMode.LOGIN,124        otpCode: String? = null,125    ): PreviewLabels = when {126        privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)127        fillMode == FillMode.OTP_FILL -> {128            val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }129            // Subtitle reads "2FA" instead of the entry title so the user can130            // tell a TOTP chip from a password chip at a glance — the entry's131            // favicon/initial on the left already conveys the entry, so a132            // duplicate-name subtitle invites the user to misread the digits133            // as their password.134            PreviewLabels(135                title = chunkOtpForDisplay(code),136                subtitle = "2FA",137                iconSource = entry.title,138                fillMode = FillMode.OTP_FILL,139            )140        }141        else -> PreviewLabels(142            entry.title,143            entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",144            entry.title,145            FillMode.LOGIN,146        )147    }148149    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =150        RemoteViews(context.packageName, R.layout.autofill_row).apply {151            setTextViewText(R.id.autofill_row_title, labels.title)152            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)153        }154155    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =156        RemoteViews(context.packageName, R.layout.autofill_row).apply {157            setTextViewText(R.id.autofill_row_title, title)158            setTextViewText(R.id.autofill_row_subtitle, subtitle)159        }160161    /**162     * Build a dataset filling the parsed fields from [entry]. Attaches the inline163     * chip when the IME offered a spec (API 30+); otherwise menu-only.164     */165    fun datasetFor(166        context: Context,167        parsed: ParsedStructure,168        entry: VaultEntry,169        inlineSpec: android.widget.inline.InlinePresentationSpec?,170        privatePreview: Boolean,171    ): Dataset? {172        // Generate the TOTP code once and route it to BOTH the chip title (when173        // the dataset fills ONLY an OTP field) and the fill value (always).174        // The FillMode.OTP_FILL chip surfaces the actual digits in the175        // keyboard strip so the user can confirm what they're about to type.176        val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }177        val fillingOnlyOtp = parsed.identityId == null &&178            parsed.passwordId == null &&179            parsed.otpId != null &&180            otpCode != null181        val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN182        val labels = previewLabels(183            privatePreview,184            entry,185            parsed.origin?.let(::extractDomain),186            fillMode = fillMode,187            otpCode = if (fillingOnlyOtp) otpCode else null,188        )189        val menu = rowFor(context, labels)190        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {191            buildInline(context, labels, inlineSpec)192        } else {193            null194        }195196        val builder = Dataset.Builder()197        var filledAny = false198        fun put(id: AutofillId?, value: String?) {199            if (id == null || value.isNullOrEmpty()) return200            val av = AutofillValue.forText(value)201            if (inline != null && Build.VERSION.SDK_INT >= 30) {202                builder.setValue(id, av, menu, inline)203            } else {204                builder.setValue(id, av, menu)205            }206            filledAny = true207        }208        put(parsed.identityId, entry.username ?: entry.email)209        put(parsed.passwordId, entry.password)210        // otpCode was computed once at the top of datasetFor so the chip can211        // display it; just route it into the fill here.212        put(parsed.otpId, otpCode)213        // A6: split single-character OTP boxes — one character per box214        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.215        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {216            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->217                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)218            }219            filledAny = true220        }221222        return if (filledAny) builder.build() else null223    }224225    /**226     * Build a dataset filling detected card fields from a card [entry].227     * Cards are not domain-matched — any detected card form offers all cards.228     */229    fun cardDatasetFor(230        context: Context,231        parsed: ParsedStructure,232        entry: VaultEntry,233        inlineSpec: android.widget.inline.InlinePresentationSpec?,234        privatePreview: Boolean,235    ): Dataset? {236        if (!entry.isCard()) return null237        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))238        val menu = rowFor(context, labels)239        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {240            buildInline(context, labels, inlineSpec)241        } else {242            null243        }244245        val builder = Dataset.Builder()246        var filledAny = false247        fun put(id: AutofillId?, value: String?) {248            if (id == null || value.isNullOrEmpty()) return249            val av = AutofillValue.forText(value)250            if (inline != null && Build.VERSION.SDK_INT >= 30) {251                builder.setValue(id, av, menu, inline)252            } else {253                builder.setValue(id, av, menu)254            }255            filledAny = true256        }257258        val digits = entry.cardNumber?.filter { it.isDigit() }259        put(parsed.cardNumberId, digits)260        put(parsed.cardExpMonthId, entry.expMonth)261        put(parsed.cardExpYearId, entry.expYear)262        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {263            val yy = entry.expYear.takeLast(2)264            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")265        }266        put(parsed.cardCvcId, entry.cvc)267        put(parsed.cardNameId, entry.cardholderName)268269        return if (filledAny) builder.build() else null270    }271272    /**273     * A "generate strong password" dataset for signup / no-match forms. Fills the274     * detected password field with a freshly generated password sized to the275     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends276     * up in the password field, so the generated value is offered for save like277     * any other fill. Menu-only (no inline chip) — there is no vault entry to278     * render. Parity for the browser's inline generator on create forms.279     */280    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {281        val passwordId = parsed.passwordId ?: return null282        val policy = parsed.passwordPolicy283        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH284        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))285        val note = policy?.constraintNote(length).orEmpty()286        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })287        return Dataset.Builder()288            .setValue(passwordId, AutofillValue.forText(password), menu)289            .build()290    }291292    /**293     * A4 identity helper: a dataset filling ONLY the signup identity field.294     * Email identities prefer the email field, usernames the username field;295     * each falls back to the other when its preferred field is absent.296     */297    fun identityDatasetFor(298        context: Context,299        parsed: ParsedStructure,300        identity: SavedIdentity,301    ): Dataset? {302        val targetId = when (identity.type) {303            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId304            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId305        } ?: return null306        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"307        val menu = simpleRow(context, identity.value, subtitle)308        return Dataset.Builder()309            .setValue(targetId, AutofillValue.forText(identity.value), menu)310            .build()311    }312313    /**314     * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site"315     * picker chip. The inline chip carries a [PendingIntent] that opens the316     * picker activity directly on tap (system menu row uses [setAuthentication]317     * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect).318     *319     * Without an attached [InlinePresentation] the dataset would render only320     * in the drop-down menu — the keyboard-row strip would never show the321     * picker, which was the v2.14.1 regression June hit.322     */323    fun searchPeachDataset(324        context: Context,325        parsed: ParsedStructure,326        inlineSpec: android.widget.inline.InlinePresentationSpec?,327        unlocked: Boolean,328        hasMatches: Boolean,329    ): Dataset {330        val labels = if (unlocked) {331            PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)332        } else {333            PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)334        }335        val menu = simpleRow(context, labels.title, labels.subtitle)336        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {337            buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))338        } else {339            null340        }341        val builder = Dataset.Builder(menu)342        (listOfNotNull(343            parsed.identityId, parsed.passwordId, parsed.otpId,344            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,345            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,346        ) + parsed.otpDigitBoxIds).forEach { id ->347            if (inline != null && Build.VERSION.SDK_INT >= 30) {348                builder.setValue(id, null, menu, inline)349            } else {350                builder.setValue(id, null, menu)351            }352        }353        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))354        return builder.build()355    }356357    /**358     * The "+ Save to Peach" create chip: tapping the chip launches MainActivity359     * on the new-entry editor pre-filled with the typed identity and origin.360     * F3 single-use token ([AutofillIntents.newEntryChipPendingIntent]) gates361     * the launch — MainActivity is exported.362     *363     * Gated to unlocked vaults: a locked vault cannot write a new entry, and364     * bouncing through MainActivity's unlock flow from an autofill chip is a365     * worse experience than waiting for the picker to unlock in-process.366     */367    fun createNewEntryDataset(368        context: Context,369        parsed: ParsedStructure,370        inlineSpec: android.widget.inline.InlinePresentationSpec?,371        unlocked: Boolean,372        origin: String?,373    ): Dataset? {374        if (!unlocked) return null375        val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null)376        val menu = simpleRow(context, labels.title, labels.subtitle)377        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {378            buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin))379        } else {380            null381        }382        val builder = Dataset.Builder(menu)383        // Drop a placeholder for every fillable id so the dataset is well-formed384        // even though no real value is provided — the chip's PendingIntent is the385        // user-facing affordance, the dataset just makes the chip eligible.386        (listOfNotNull(387            parsed.identityId, parsed.passwordId, parsed.otpId,388            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,389            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,390        ) + parsed.otpDigitBoxIds).forEach { id ->391            if (inline != null && Build.VERSION.SDK_INT >= 30) {392                builder.setValue(id, null, menu, inline)393            } else {394                builder.setValue(id, null, menu)395            }396        }397        return builder.build()398    }399400    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but401    // reading the slice to build an InlinePresentation is the documented usage.402    @SuppressLint("RestrictedApi")403    @RequiresApi(30)404    private fun buildInline(405        context: Context,406        labels: PreviewLabels,407        spec: android.widget.inline.InlinePresentationSpec,408        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),409    ): InlinePresentation? = try {410        val style = spec.style411        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {412            null413        } else {414            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }415                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)416            val slice = InlineSuggestionUi.newContentBuilder(chipPending)417                .setTitle(labels.title)418                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }419                .setStartIcon(startIcon)420                .build()421                .slice422            InlinePresentation(slice, spec, false)423        }424    } catch (_: Throwable) {425        // A non-conforming IME spec must never crash the fill — degrade to menu.426        null427    }428}