AutofillResponseBuilder.kt

8.5 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     * Strict registrable-domain matching. Missing, malformed, local, and IP origins30     * return no entries; callers must route those requests through the guarded picker.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    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) {43        AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain)44        AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains(45            entries,46            parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(),47        )48        AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList())49    }5051    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =52        MatchResult(53            hits = entries.filter { entry ->54                domains.any { domain -> entry.matchesOriginDomain(domain) }55            },56            rest = emptyList(),57        )5859    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =60        sequenceOf(url)61            .plus(additionalUrls.orEmpty().asSequence())62            .filterNotNull()63            .mapNotNull(::extractDomain)64            .any { it == originDomain }6566    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {67        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""68        return RemoteViews(context.packageName, R.layout.autofill_row).apply {69            setTextViewText(R.id.autofill_row_title, entry.title)70            setTextViewText(R.id.autofill_row_subtitle, subtitle)71        }72    }7374    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =75        RemoteViews(context.packageName, R.layout.autofill_row).apply {76            setTextViewText(R.id.autofill_row_title, title)77            setTextViewText(R.id.autofill_row_subtitle, subtitle)78        }7980    /**81     * Build a dataset filling the parsed fields from [entry]. Attaches the inline82     * chip when the IME offered a spec (API 30+); otherwise menu-only.83     */84    fun datasetFor(85        context: Context,86        parsed: ParsedStructure,87        entry: VaultEntry,88        inlineSpec: android.widget.inline.InlinePresentationSpec?,89    ): Dataset? {90        val menu = menuRow(context, entry)91        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {92            buildInline(context, entry, inlineSpec)93        } else {94            null95        }9697        val builder = Dataset.Builder()98        var filledAny = false99        fun put(id: AutofillId?, value: String?) {100            if (id == null || value.isNullOrEmpty()) return101            val av = AutofillValue.forText(value)102            if (inline != null && Build.VERSION.SDK_INT >= 30) {103                builder.setValue(id, av, menu, inline)104            } else {105                builder.setValue(id, av, menu)106            }107            filledAny = true108        }109        put(parsed.identityId, entry.username ?: entry.email)110        put(parsed.passwordId, entry.password)111        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })112113        return if (filledAny) builder.build() else null114    }115116    /**117     * Build a dataset filling detected card fields from a card [entry].118     * Cards are not domain-matched — any detected card form offers all cards.119     */120    fun cardDatasetFor(121        context: Context,122        parsed: ParsedStructure,123        entry: VaultEntry,124        inlineSpec: android.widget.inline.InlinePresentationSpec?,125    ): Dataset? {126        if (!entry.isCard()) return null127        val menu = menuRow(context, entry)128        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {129            buildInline(context, entry, inlineSpec)130        } else {131            null132        }133134        val builder = Dataset.Builder()135        var filledAny = false136        fun put(id: AutofillId?, value: String?) {137            if (id == null || value.isNullOrEmpty()) return138            val av = AutofillValue.forText(value)139            if (inline != null && Build.VERSION.SDK_INT >= 30) {140                builder.setValue(id, av, menu, inline)141            } else {142                builder.setValue(id, av, menu)143            }144            filledAny = true145        }146147        val digits = entry.cardNumber?.filter { it.isDigit() }148        put(parsed.cardNumberId, digits)149        put(parsed.cardExpMonthId, entry.expMonth)150        put(parsed.cardExpYearId, entry.expYear)151        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {152            val yy = entry.expYear.takeLast(2)153            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")154        }155        put(parsed.cardCvcId, entry.cvc)156        put(parsed.cardNameId, entry.cardholderName)157158        return if (filledAny) builder.build() else null159    }160161    /**162     * A "generate strong password" dataset for signup / no-match forms. Fills the163     * detected password field with a freshly generated password sized to the164     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends165     * up in the password field, so the generated value is offered for save like166     * any other fill. Menu-only (no inline chip) — there is no vault entry to167     * render. Parity for the browser's inline generator on create forms.168     */169    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {170        val passwordId = parsed.passwordId ?: return null171        val policy = parsed.passwordPolicy172        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH173        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))174        val note = policy?.constraintNote(length).orEmpty()175        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })176        return Dataset.Builder()177            .setValue(passwordId, AutofillValue.forText(password), menu)178            .build()179    }180181    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but182    // reading the slice to build an InlinePresentation is the documented usage.183    @SuppressLint("RestrictedApi")184    @RequiresApi(30)185    private fun buildInline(186        context: Context,187        entry: VaultEntry,188        spec: android.widget.inline.InlinePresentationSpec,189    ): InlinePresentation? = try {190        val style = spec.style191        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {192            null193        } else {194            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""195            val pending = AutofillIntents.noopChipIntent(context)196            val slice = InlineSuggestionUi.newContentBuilder(pending)197                .setTitle(entry.title)198                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }199                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))200                .build()201                .slice202            InlinePresentation(slice, spec, false)203        }204    } catch (_: Throwable) {205        // A non-conforming IME spec must never crash the fill — degrade to menu.206        null207    }208}