package com.peachpasswords.autofill import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.service.autofill.Dataset import android.service.autofill.InlinePresentation import android.view.autofill.AutofillId import android.view.autofill.AutofillValue import android.widget.RemoteViews import androidx.annotation.RequiresApi import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.autofill.inline.UiVersions import com.peachpasswords.R import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.VaultEntry /** * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu * RemoteViews row, the inline keyboard chip, and the actual field values. * TOTP is filled as a freshly-generated code; the seed never leaves the vault. */ object AutofillResponseBuilder { /** Max entries shown when there is no web origin to match strictly against. */ private const val FALLBACK_LIMIT = 6 /** * Strict origin match (MF-7). [hits] are entries whose registrable domain * (eTLD+1, via the public suffix list) exactly equals the request origin's — * never a loose substring, so `github.com` can't elicit a `notgithub.com` * entry. [rest] is a small fallback shown ONLY when the origin has no * registrable domain (a native package or no origin at all); for a real web * origin it is always empty, so unrelated entries are never offered. */ data class MatchResult(val hits: List, val rest: List) { val all: List get() = hits + rest } fun matchEntries(entries: List, origin: String?): MatchResult { if (origin.isNullOrBlank()) return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT)) val originDomain = extractDomain(origin) ?: return MatchResult(emptyList(), entries.take(FALLBACK_LIMIT)) val hits = entries.filter { entry -> entry.matchesOriginDomain(originDomain) } return MatchResult(hits = hits, rest = emptyList()) } private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean = sequenceOf(url) .plus(additionalUrls.orEmpty().asSequence()) .filterNotNull() .mapNotNull(::extractDomain) .any { it == originDomain } fun menuRow(context: Context, entry: VaultEntry): RemoteViews { val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" return RemoteViews(context.packageName, R.layout.autofill_row).apply { setTextViewText(R.id.autofill_row_title, entry.title) setTextViewText(R.id.autofill_row_subtitle, subtitle) } } fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews = RemoteViews(context.packageName, R.layout.autofill_row).apply { setTextViewText(R.id.autofill_row_title, title) setTextViewText(R.id.autofill_row_subtitle, subtitle) } /** * Build a dataset filling the parsed fields from [entry]. Attaches the inline * chip when the IME offered a spec (API 30+); otherwise menu-only. */ fun datasetFor( context: Context, parsed: ParsedStructure, entry: VaultEntry, inlineSpec: android.widget.inline.InlinePresentationSpec?, ): Dataset? { val menu = menuRow(context, entry) val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) { buildInline(context, entry, inlineSpec) } else { null } val builder = Dataset.Builder() var filledAny = false fun put(id: AutofillId?, value: String?) { if (id == null || value.isNullOrEmpty()) return val av = AutofillValue.forText(value) if (inline != null && Build.VERSION.SDK_INT >= 30) { builder.setValue(id, av, menu, inline) } else { builder.setValue(id, av, menu) } filledAny = true } put(parsed.identityId, entry.username ?: entry.email) put(parsed.passwordId, entry.password) put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) }) return if (filledAny) builder.build() else null } // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but // reading the slice to build an InlinePresentation is the documented usage. @SuppressLint("RestrictedApi") @RequiresApi(30) private fun buildInline( context: Context, entry: VaultEntry, spec: android.widget.inline.InlinePresentationSpec, ): InlinePresentation? = try { val style = spec.style if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) { null } else { val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" val pending = AutofillIntents.noopChipIntent(context) val slice = InlineSuggestionUi.newContentBuilder(pending) .setTitle(entry.title) .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) } .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)) .build() .slice InlinePresentation(slice, spec, false) } } catch (_: Throwable) { // A non-conforming IME spec must never crash the fill — degrade to menu. null } }