AutofillResponseBuilder.kt

5.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.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 -> entry.matchesOriginDomain(originDomain) }46        return MatchResult(hits = hits, rest = emptyList())47    }4849    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =50        sequenceOf(url)51            .plus(additionalUrls.orEmpty().asSequence())52            .filterNotNull()53            .mapNotNull(::extractDomain)54            .any { it == originDomain }5556    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {57        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""58        return RemoteViews(context.packageName, R.layout.autofill_row).apply {59            setTextViewText(R.id.autofill_row_title, entry.title)60            setTextViewText(R.id.autofill_row_subtitle, subtitle)61        }62    }6364    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =65        RemoteViews(context.packageName, R.layout.autofill_row).apply {66            setTextViewText(R.id.autofill_row_title, title)67            setTextViewText(R.id.autofill_row_subtitle, subtitle)68        }6970    /**71     * Build a dataset filling the parsed fields from [entry]. Attaches the inline72     * chip when the IME offered a spec (API 30+); otherwise menu-only.73     */74    fun datasetFor(75        context: Context,76        parsed: ParsedStructure,77        entry: VaultEntry,78        inlineSpec: android.widget.inline.InlinePresentationSpec?,79    ): Dataset? {80        val menu = menuRow(context, entry)81        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {82            buildInline(context, entry, inlineSpec)83        } else {84            null85        }8687        val builder = Dataset.Builder()88        var filledAny = false89        fun put(id: AutofillId?, value: String?) {90            if (id == null || value.isNullOrEmpty()) return91            val av = AutofillValue.forText(value)92            if (inline != null && Build.VERSION.SDK_INT >= 30) {93                builder.setValue(id, av, menu, inline)94            } else {95                builder.setValue(id, av, menu)96            }97            filledAny = true98        }99        put(parsed.identityId, entry.username ?: entry.email)100        put(parsed.passwordId, entry.password)101        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })102103        return if (filledAny) builder.build() else null104    }105106    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but107    // reading the slice to build an InlinePresentation is the documented usage.108    @SuppressLint("RestrictedApi")109    @RequiresApi(30)110    private fun buildInline(111        context: Context,112        entry: VaultEntry,113        spec: android.widget.inline.InlinePresentationSpec,114    ): InlinePresentation? = try {115        val style = spec.style116        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {117            null118        } else {119            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""120            val pending = AutofillIntents.noopChipIntent(context)121            val slice = InlineSuggestionUi.newContentBuilder(pending)122                .setTitle(entry.title)123                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }124                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))125                .build()126                .slice127            InlinePresentation(slice, spec, false)128        }129    } catch (_: Throwable) {130        // A non-conforming IME spec must never crash the fill — degrade to menu.131        null132    }133}