AutofillResponseBuilder.kt

9.7 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    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =68        sequenceOf(url)69            .plus(additionalUrls.orEmpty().asSequence())70            .filterNotNull()71            .mapNotNull(::extractDomain)72            .any { it == originDomain }7374    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {75        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""76        return RemoteViews(context.packageName, R.layout.autofill_row).apply {77            setTextViewText(R.id.autofill_row_title, entry.title)78            setTextViewText(R.id.autofill_row_subtitle, subtitle)79        }80    }8182    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =83        RemoteViews(context.packageName, R.layout.autofill_row).apply {84            setTextViewText(R.id.autofill_row_title, title)85            setTextViewText(R.id.autofill_row_subtitle, subtitle)86        }8788    /**89     * Build a dataset filling the parsed fields from [entry]. Attaches the inline90     * chip when the IME offered a spec (API 30+); otherwise menu-only.91     */92    fun datasetFor(93        context: Context,94        parsed: ParsedStructure,95        entry: VaultEntry,96        inlineSpec: android.widget.inline.InlinePresentationSpec?,97    ): Dataset? {98        val menu = menuRow(context, entry)99        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {100            buildInline(context, entry, inlineSpec)101        } else {102            null103        }104105        val builder = Dataset.Builder()106        var filledAny = false107        fun put(id: AutofillId?, value: String?) {108            if (id == null || value.isNullOrEmpty()) return109            val av = AutofillValue.forText(value)110            if (inline != null && Build.VERSION.SDK_INT >= 30) {111                builder.setValue(id, av, menu, inline)112            } else {113                builder.setValue(id, av, menu)114            }115            filledAny = true116        }117        put(parsed.identityId, entry.username ?: entry.email)118        put(parsed.passwordId, entry.password)119        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })120121        return if (filledAny) builder.build() else null122    }123124    /**125     * Build a dataset filling detected card fields from a card [entry].126     * Cards are not domain-matched — any detected card form offers all cards.127     */128    fun cardDatasetFor(129        context: Context,130        parsed: ParsedStructure,131        entry: VaultEntry,132        inlineSpec: android.widget.inline.InlinePresentationSpec?,133    ): Dataset? {134        if (!entry.isCard()) return null135        val menu = menuRow(context, entry)136        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {137            buildInline(context, entry, inlineSpec)138        } else {139            null140        }141142        val builder = Dataset.Builder()143        var filledAny = false144        fun put(id: AutofillId?, value: String?) {145            if (id == null || value.isNullOrEmpty()) return146            val av = AutofillValue.forText(value)147            if (inline != null && Build.VERSION.SDK_INT >= 30) {148                builder.setValue(id, av, menu, inline)149            } else {150                builder.setValue(id, av, menu)151            }152            filledAny = true153        }154155        val digits = entry.cardNumber?.filter { it.isDigit() }156        put(parsed.cardNumberId, digits)157        put(parsed.cardExpMonthId, entry.expMonth)158        put(parsed.cardExpYearId, entry.expYear)159        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {160            val yy = entry.expYear.takeLast(2)161            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")162        }163        put(parsed.cardCvcId, entry.cvc)164        put(parsed.cardNameId, entry.cardholderName)165166        return if (filledAny) builder.build() else null167    }168169    /**170     * A "generate strong password" dataset for signup / no-match forms. Fills the171     * detected password field with a freshly generated password sized to the172     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends173     * up in the password field, so the generated value is offered for save like174     * any other fill. Menu-only (no inline chip) — there is no vault entry to175     * render. Parity for the browser's inline generator on create forms.176     */177    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {178        val passwordId = parsed.passwordId ?: return null179        val policy = parsed.passwordPolicy180        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH181        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))182        val note = policy?.constraintNote(length).orEmpty()183        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })184        return Dataset.Builder()185            .setValue(passwordId, AutofillValue.forText(password), menu)186            .build()187    }188189    /**190     * A4 identity helper: a dataset filling ONLY the signup identity field.191     * Email identities prefer the email field, usernames the username field;192     * each falls back to the other when its preferred field is absent.193     */194    fun identityDatasetFor(195        context: Context,196        parsed: ParsedStructure,197        identity: SavedIdentity,198    ): Dataset? {199        val targetId = when (identity.type) {200            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId201            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId202        } ?: return null203        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"204        val menu = simpleRow(context, identity.value, subtitle)205        return Dataset.Builder()206            .setValue(targetId, AutofillValue.forText(identity.value), menu)207            .build()208    }209210    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but211    // reading the slice to build an InlinePresentation is the documented usage.212    @SuppressLint("RestrictedApi")213    @RequiresApi(30)214    private fun buildInline(215        context: Context,216        entry: VaultEntry,217        spec: android.widget.inline.InlinePresentationSpec,218    ): InlinePresentation? = try {219        val style = spec.style220        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {221            null222        } else {223            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""224            val pending = AutofillIntents.noopChipIntent(context)225            val slice = InlineSuggestionUi.newContentBuilder(pending)226                .setTitle(entry.title)227                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }228                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))229                .build()230                .slice231            InlinePresentation(slice, spec, false)232        }233    } catch (_: Throwable) {234        // A non-conforming IME spec must never crash the fill — degrade to menu.235        null236    }237}