AutofillResponseBuilder.kt

7.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.VaultEntry18import com.peachpasswords.vault.isCard1920/**21 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu22 * RemoteViews row, the inline keyboard chip, and the actual field values.23 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.24 */25object AutofillResponseBuilder {2627    /**28     * Strict registrable-domain matching. Missing, malformed, local, and IP origins29     * return no entries; callers must route those requests through the guarded picker.30     */31    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {32        val all: List<VaultEntry> get() = hits + rest33    }3435    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {36        val originDomain = origin?.let(::extractDomain)37            ?: return MatchResult(emptyList(), emptyList())38        return matchDomains(entries, setOf(originDomain))39    }4041    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult = when (parsed.originTrust) {42        AutofillOriginTrust.TRUSTED_WEB -> matchEntries(entries, parsed.webDomain)43        AutofillOriginTrust.TRUSTED_NATIVE -> matchDomains(44            entries,45            parsed.verifiedDomains.mapNotNull(::extractDomain).toSet(),46        )47        AutofillOriginTrust.UNKNOWN -> MatchResult(emptyList(), emptyList())48    }4950    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =51        MatchResult(52            hits = entries.filter { entry ->53                domains.any { domain -> entry.matchesOriginDomain(domain) }54            },55            rest = emptyList(),56        )5758    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =59        sequenceOf(url)60            .plus(additionalUrls.orEmpty().asSequence())61            .filterNotNull()62            .mapNotNull(::extractDomain)63            .any { it == originDomain }6465    fun menuRow(context: Context, entry: VaultEntry): RemoteViews {66        val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""67        return RemoteViews(context.packageName, R.layout.autofill_row).apply {68            setTextViewText(R.id.autofill_row_title, entry.title)69            setTextViewText(R.id.autofill_row_subtitle, subtitle)70        }71    }7273    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =74        RemoteViews(context.packageName, R.layout.autofill_row).apply {75            setTextViewText(R.id.autofill_row_title, title)76            setTextViewText(R.id.autofill_row_subtitle, subtitle)77        }7879    /**80     * Build a dataset filling the parsed fields from [entry]. Attaches the inline81     * chip when the IME offered a spec (API 30+); otherwise menu-only.82     */83    fun datasetFor(84        context: Context,85        parsed: ParsedStructure,86        entry: VaultEntry,87        inlineSpec: android.widget.inline.InlinePresentationSpec?,88    ): Dataset? {89        val menu = menuRow(context, entry)90        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {91            buildInline(context, entry, inlineSpec)92        } else {93            null94        }9596        val builder = Dataset.Builder()97        var filledAny = false98        fun put(id: AutofillId?, value: String?) {99            if (id == null || value.isNullOrEmpty()) return100            val av = AutofillValue.forText(value)101            if (inline != null && Build.VERSION.SDK_INT >= 30) {102                builder.setValue(id, av, menu, inline)103            } else {104                builder.setValue(id, av, menu)105            }106            filledAny = true107        }108        put(parsed.identityId, entry.username ?: entry.email)109        put(parsed.passwordId, entry.password)110        put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })111112        return if (filledAny) builder.build() else null113    }114115    /**116     * Build a dataset filling detected card fields from a card [entry].117     * Cards are not domain-matched — any detected card form offers all cards.118     */119    fun cardDatasetFor(120        context: Context,121        parsed: ParsedStructure,122        entry: VaultEntry,123        inlineSpec: android.widget.inline.InlinePresentationSpec?,124    ): Dataset? {125        if (!entry.isCard()) return null126        val menu = menuRow(context, entry)127        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {128            buildInline(context, entry, inlineSpec)129        } else {130            null131        }132133        val builder = Dataset.Builder()134        var filledAny = false135        fun put(id: AutofillId?, value: String?) {136            if (id == null || value.isNullOrEmpty()) return137            val av = AutofillValue.forText(value)138            if (inline != null && Build.VERSION.SDK_INT >= 30) {139                builder.setValue(id, av, menu, inline)140            } else {141                builder.setValue(id, av, menu)142            }143            filledAny = true144        }145146        val digits = entry.cardNumber?.filter { it.isDigit() }147        put(parsed.cardNumberId, digits)148        put(parsed.cardExpMonthId, entry.expMonth)149        put(parsed.cardExpYearId, entry.expYear)150        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {151            val yy = entry.expYear.takeLast(2)152            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")153        }154        put(parsed.cardCvcId, entry.cvc)155        put(parsed.cardNameId, entry.cardholderName)156157        return if (filledAny) builder.build() else null158    }159160    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but161    // reading the slice to build an InlinePresentation is the documented usage.162    @SuppressLint("RestrictedApi")163    @RequiresApi(30)164    private fun buildInline(165        context: Context,166        entry: VaultEntry,167        spec: android.widget.inline.InlinePresentationSpec,168    ): InlinePresentation? = try {169        val style = spec.style170        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {171            null172        } else {173            val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""174            val pending = AutofillIntents.noopChipIntent(context)175            val slice = InlineSuggestionUi.newContentBuilder(pending)176                .setTitle(entry.title)177                .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }178                .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))179                .build()180                .slice181            InlinePresentation(slice, spec, false)182        }183    } catch (_: Throwable) {184        // A non-conforming IME spec must never crash the fill — degrade to menu.185        null186    }187}