AutofillResponseBuilder.kt

5.3 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.TOTPGenerator17import com.peachpasswords.vault.VaultEntry1819/**20 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu21 * RemoteViews row, the inline keyboard chip, and the actual field values.22 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.23 */24object AutofillResponseBuilder {2526    /** Max entries shown when there is no web origin to match strictly against. */27    private const val FALLBACK_LIMIT = 62829    /**30     * Strict origin match (MF-7). [hits] are entries whose registrable domain31     * (eTLD+1, via the public suffix list) exactly equals the request origin's —32     * never a loose substring, so `github.com` can't elicit a `notgithub.com`33     * entry. [rest] is a small fallback shown ONLY when the origin has no34     * registrable domain (a native package or no origin at all); for a real web35     * origin it is always empty, so unrelated entries are never offered.36     */37    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {38        val all: List<VaultEntry> get() = hits + rest39    }4041    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {42        if (origin.isNullOrBlank()) return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT))43        val originDomain = extractDomain(origin)44            ?: return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT))45        val hits = entries.filter { entry ->46            val entryDomain = extractDomain(entry.url ?: "") ?: return@filter false47            entryDomain == originDomain48        }49        return MatchResult(hits = hits, rest = emptyList())50    }5152    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {53        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""54        return RemoteViews(context.packageName, R.layout.autofill_row).apply {55            setTextViewText(R.id.autofill_row_title, entry.title)56            setTextViewText(R.id.autofill_row_subtitle, subtitle)57        }58    }5960    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =61        RemoteViews(context.packageName, R.layout.autofill_row).apply {62            setTextViewText(R.id.autofill_row_title, title)63            setTextViewText(R.id.autofill_row_subtitle, subtitle)64        }6566    /**67     * Build a dataset filling the parsed fields from [entry]. Attaches the inline68     * chip when the IME offered a spec (API 30+); otherwise menu-only.69     */70    fun datasetFor(71        context: Context,72        parsed: ParsedStructure,73        entry: VaultEntry,74        inlineSpec: android.widget.inline.InlinePresentationSpec?,75    ): Dataset? {76        val menu = menuRow(context, entry)77        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {78            buildInline(context, entry, inlineSpec)79        } else {80            null81        }8283        val builder = Dataset.Builder()84        var filledAny = false85        fun put(id: AutofillId?, value: String?) {86            if (id == null || value.isNullOrEmpty()) return87            val av = AutofillValue.forText(value)88            if (inline != null && Build.VERSION.SDK_INT >= 30) {89                builder.setValue(id, av, menu, inline)90            } else {91                builder.setValue(id, av, menu)92            }93            filledAny = true94        }95        put(parsed.identityId, entry.username ?: entry.email)96        put(parsed.passwordId, entry.password)97        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })9899        return if (filledAny) builder.build() else null100    }101102    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but103    // reading the slice to build an InlinePresentation is the documented usage.104    @SuppressLint("RestrictedApi")105    @RequiresApi(30)106    private fun buildInline(107        context: Context,108        entry: VaultEntry,109        spec: android.widget.inline.InlinePresentationSpec,110    ): InlinePresentation? = try {111        val style = spec.style112        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {113            null114        } else {115            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""116            val pending = AutofillIntents.noopChipIntent(context)117            val slice = InlineSuggestionUi.newContentBuilder(pending)118                .setTitle(entry.title)119                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }120                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))121                .build()122                .slice123            InlinePresentation(slice, spec, false)124        }125    } catch (_: Throwable) {126        // A non-conforming IME spec must never crash the fill — degrade to menu.127        null128    }129}