AutofillResponseBuilder.kt

11.6 KB · raw

1package com.peachpasswords.autofill23import android.annotation.SuppressLint4import android.content.Context5import android.os.Build6import android.service.autofill.Dataset7import android.service.autofill.InlinePresentation8import android.view.autofill.AutofillId9import android.view.autofill.AutofillValue10import android.widget.RemoteViews11import androidx.annotation.RequiresApi12import androidx.autofill.inline.v1.InlineSuggestionUi13import androidx.autofill.inline.UiVersions14import com.peachpasswords.R15import com.peachpasswords.shared.extractDomain16import com.peachpasswords.vault.PasswordGenerator17import com.peachpasswords.vault.TOTPGenerator18import com.peachpasswords.vault.VaultEntry19import com.peachpasswords.vault.isCard2021/**22 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu23 * RemoteViews row, the inline keyboard chip, and the actual field values.24 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.25 */26object AutofillResponseBuilder {2728    /**29     * Registrable-domain matching. Missing, malformed, local, and IP origins30     * return no entries (there is nothing to rank against).31     */32    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {33        val all: List<VaultEntry> get() = hits + rest34    }3536    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {37        val originDomain = origin?.let(::extractDomain)38            ?: return MatchResult(emptyList(), emptyList())39        return matchDomains(entries, setOf(originDomain))40    }4142    /**43     * Rank entries for an origin with no caller-trust gate (origin verification44     * removed, queue #3). Web origins match by the page domain; native apps match45     * by their app-link domains or display title. Blank origins match nothing.46     */47    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {48        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()49        val byDomain = matchDomains(entries, domains).hits50        val appTitle = parsed.appTitle?.trim()51        val byTitle = if (appTitle.isNullOrBlank()) {52            emptyList()53        } else {54            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }55        }56        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())57    }5859    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =60        MatchResult(61            hits = entries.filter { entry ->62                domains.any { domain -> entry.matchesOriginDomain(domain) }63            },64            rest = emptyList(),65        )6667    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */68    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =69        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7071    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =72        sequenceOf(url)73            .plus(additionalUrls.orEmpty().asSequence())74            .filterNotNull()75            .mapNotNull(::extractDomain)76            .any { it == originDomain }7778    /**79     * What a suggestion row or keyboard chip shows about an entry.80     * [iconSource] seeds the chip monogram; null keeps the Peach mark81     * (always the case in privacy mode — initials would leak titles too).82     */83    data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)8485    /**86     * Privacy previews: with [privatePreview] the rows and chips render only87     * the Peach brand and the caller's domain — no entry titles or usernames88     * in the IME's suggestion strip. The values still fill on tap.89     */90    fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =91        if (privatePreview) {92            PreviewLabels("Peach", originDomain ?: "", null)93        } else {94            PreviewLabels(95                entry.title,96                entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",97                entry.title,98            )99        }100101    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =102        RemoteViews(context.packageName, R.layout.autofill_row).apply {103            setTextViewText(R.id.autofill_row_title, labels.title)104            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)105        }106107    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =108        RemoteViews(context.packageName, R.layout.autofill_row).apply {109            setTextViewText(R.id.autofill_row_title, title)110            setTextViewText(R.id.autofill_row_subtitle, subtitle)111        }112113    /**114     * Build a dataset filling the parsed fields from [entry]. Attaches the inline115     * chip when the IME offered a spec (API 30+); otherwise menu-only.116     */117    fun datasetFor(118        context: Context,119        parsed: ParsedStructure,120        entry: VaultEntry,121        inlineSpec: android.widget.inline.InlinePresentationSpec?,122        privatePreview: Boolean,123    ): Dataset? {124        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))125        val menu = rowFor(context, labels)126        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {127            buildInline(context, labels, inlineSpec)128        } else {129            null130        }131132        val builder = Dataset.Builder()133        var filledAny = false134        fun put(id: AutofillId?, value: String?) {135            if (id == null || value.isNullOrEmpty()) return136            val av = AutofillValue.forText(value)137            if (inline != null && Build.VERSION.SDK_INT >= 30) {138                builder.setValue(id, av, menu, inline)139            } else {140                builder.setValue(id, av, menu)141            }142            filledAny = true143        }144        put(parsed.identityId, entry.username ?: entry.email)145        put(parsed.passwordId, entry.password)146        val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }147        put(parsed.otpId, otpCode)148        // A6: split single-character OTP boxes — one character per box149        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.150        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {151            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->152                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)153            }154            filledAny = true155        }156157        return if (filledAny) builder.build() else null158    }159160    /**161     * Build a dataset filling detected card fields from a card [entry].162     * Cards are not domain-matched — any detected card form offers all cards.163     */164    fun cardDatasetFor(165        context: Context,166        parsed: ParsedStructure,167        entry: VaultEntry,168        inlineSpec: android.widget.inline.InlinePresentationSpec?,169        privatePreview: Boolean,170    ): Dataset? {171        if (!entry.isCard()) return null172        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))173        val menu = rowFor(context, labels)174        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {175            buildInline(context, labels, inlineSpec)176        } else {177            null178        }179180        val builder = Dataset.Builder()181        var filledAny = false182        fun put(id: AutofillId?, value: String?) {183            if (id == null || value.isNullOrEmpty()) return184            val av = AutofillValue.forText(value)185            if (inline != null && Build.VERSION.SDK_INT >= 30) {186                builder.setValue(id, av, menu, inline)187            } else {188                builder.setValue(id, av, menu)189            }190            filledAny = true191        }192193        val digits = entry.cardNumber?.filter { it.isDigit() }194        put(parsed.cardNumberId, digits)195        put(parsed.cardExpMonthId, entry.expMonth)196        put(parsed.cardExpYearId, entry.expYear)197        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {198            val yy = entry.expYear.takeLast(2)199            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")200        }201        put(parsed.cardCvcId, entry.cvc)202        put(parsed.cardNameId, entry.cardholderName)203204        return if (filledAny) builder.build() else null205    }206207    /**208     * A "generate strong password" dataset for signup / no-match forms. Fills the209     * detected password field with a freshly generated password sized to the210     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends211     * up in the password field, so the generated value is offered for save like212     * any other fill. Menu-only (no inline chip) — there is no vault entry to213     * render. Parity for the browser's inline generator on create forms.214     */215    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {216        val passwordId = parsed.passwordId ?: return null217        val policy = parsed.passwordPolicy218        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH219        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))220        val note = policy?.constraintNote(length).orEmpty()221        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })222        return Dataset.Builder()223            .setValue(passwordId, AutofillValue.forText(password), menu)224            .build()225    }226227    /**228     * A4 identity helper: a dataset filling ONLY the signup identity field.229     * Email identities prefer the email field, usernames the username field;230     * each falls back to the other when its preferred field is absent.231     */232    fun identityDatasetFor(233        context: Context,234        parsed: ParsedStructure,235        identity: SavedIdentity,236    ): Dataset? {237        val targetId = when (identity.type) {238            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId239            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId240        } ?: return null241        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"242        val menu = simpleRow(context, identity.value, subtitle)243        return Dataset.Builder()244            .setValue(targetId, AutofillValue.forText(identity.value), menu)245            .build()246    }247248    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but249    // reading the slice to build an InlinePresentation is the documented usage.250    @SuppressLint("RestrictedApi")251    @RequiresApi(30)252    private fun buildInline(253        context: Context,254        labels: PreviewLabels,255        spec: android.widget.inline.InlinePresentationSpec,256    ): InlinePresentation? = try {257        val style = spec.style258        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {259            null260        } else {261            val pending = AutofillIntents.noopChipIntent(context)262            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }263                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)264            val slice = InlineSuggestionUi.newContentBuilder(pending)265                .setTitle(labels.title)266                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }267                .setStartIcon(startIcon)268                .build()269                .slice270            InlinePresentation(slice, spec, false)271        }272    } catch (_: Throwable) {273        // A non-conforming IME spec must never crash the fill — degrade to menu.274        null275    }276}