AutofillResponseBuilder.kt

5.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.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    /**27     * Strict registrable-domain matching. Missing, malformed, local, and IP origins28     * return no entries; callers must route those requests through the guarded picker.29     */30    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {31        val all: List<VaultEntry> get() = hits + rest32    }3334    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {35        val originDomain = origin?.let(::extractDomain)36            ?: return MatchResult(emptyList(), emptyList())37        return matchDomains(entries, setOf(originDomain))38    }3940    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) {41        AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain)42        AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains(43            entries,44            parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(),45        )46        AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList())47    }4849    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =50        MatchResult(51            hits = entries.filter { entry ->52                domains.any { domain -> entry.matchesOriginDomain(domain) }53            },54            rest = emptyList(),55        )5657    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =58        sequenceOf(url)59            .plus(additionalUrls.orEmpty().asSequence())60            .filterNotNull()61            .mapNotNull(::extractDomain)62            .any { it == originDomain }6364    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {65        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""66        return RemoteViews(context.packageName, R.layout.autofill_row).apply {67            setTextViewText(R.id.autofill_row_title, entry.title)68            setTextViewText(R.id.autofill_row_subtitle, subtitle)69        }70    }7172    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =73        RemoteViews(context.packageName, R.layout.autofill_row).apply {74            setTextViewText(R.id.autofill_row_title, title)75            setTextViewText(R.id.autofill_row_subtitle, subtitle)76        }7778    /**79     * Build a dataset filling the parsed fields from [entry]. Attaches the inline80     * chip when the IME offered a spec (API 30+); otherwise menu-only.81     */82    fun datasetFor(83        context: Context,84        parsed: ParsedStructure,85        entry: VaultEntry,86        inlineSpec: android.widget.inline.InlinePresentationSpec?,87    ): Dataset? {88        val menu = menuRow(context, entry)89        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {90            buildInline(context, entry, inlineSpec)91        } else {92            null93        }9495        val builder = Dataset.Builder()96        var filledAny = false97        fun put(id: AutofillId?, value: String?) {98            if (id == null || value.isNullOrEmpty()) return99            val av = AutofillValue.forText(value)100            if (inline != null && Build.VERSION.SDK_INT >= 30) {101                builder.setValue(id, av, menu, inline)102            } else {103                builder.setValue(id, av, menu)104            }105            filledAny = true106        }107        put(parsed.identityId, entry.username ?: entry.email)108        put(parsed.passwordId, entry.password)109        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })110111        return if (filledAny) builder.build() else null112    }113114    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but115    // reading the slice to build an InlinePresentation is the documented usage.116    @SuppressLint("RestrictedApi")117    @RequiresApi(30)118    private fun buildInline(119        context: Context,120        entry: VaultEntry,121        spec: android.widget.inline.InlinePresentationSpec,122    ): InlinePresentation? = try {123        val style = spec.style124        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {125            null126        } else {127            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""128            val pending = AutofillIntents.noopChipIntent(context)129            val slice = InlineSuggestionUi.newContentBuilder(pending)130                .setTitle(entry.title)131                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }132                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))133                .build()134                .slice135            InlinePresentation(slice, spec, false)136        }137    } catch (_: Throwable) {138        // A non-conforming IME spec must never crash the fill — degrade to menu.139        null140    }141}