AutofillResponseBuilder.kt

15.5 KB · raw

1package com.peachpasswords.autofill23import android.annotation.SuppressLint4import android.app.PendingIntent5import android.content.Context6import android.os.Build7import android.service.autofill.Dataset8import android.service.autofill.InlinePresentation9import android.view.autofill.AutofillId10import android.view.autofill.AutofillValue11import android.widget.RemoteViews12import androidx.annotation.RequiresApi13import androidx.autofill.inline.v1.InlineSuggestionUi14import androidx.autofill.inline.UiVersions15import com.peachpasswords.R16import com.peachpasswords.shared.extractDomain17import com.peachpasswords.vault.PasswordGenerator18import com.peachpasswords.vault.TOTPGenerator19import com.peachpasswords.vault.VaultEntry20import com.peachpasswords.vault.isCard2122/**23 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu24 * RemoteViews row, the inline keyboard chip, and the actual field values.25 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.26 */27object AutofillResponseBuilder {2829    /**30     * Registrable-domain matching. Missing, malformed, local, and IP origins31     * return no entries (there is nothing to rank against).32     */33    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {34        val all: List<VaultEntry> get() = hits + rest35    }3637    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {38        val originDomain = origin?.let(::extractDomain)39            ?: return MatchResult(emptyList(), emptyList())40        return matchDomains(entries, setOf(originDomain))41    }4243    /**44     * Rank entries for an origin with no caller-trust gate (origin verification45     * removed, queue #3). Web origins match by the page domain; native apps match46     * by their app-link domains or display title. Blank origins match nothing.47     */48    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {49        val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()50        val byDomain = matchDomains(entries, domains).hits51        val appTitle = parsed.appTitle?.trim()52        val byTitle = if (appTitle.isNullOrBlank()) {53            emptyList()54        } else {55            entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }56        }57        return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())58    }5960    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =61        MatchResult(62            hits = entries.filter { entry ->63                domains.any { domain -> entry.matchesOriginDomain(domain) }64            },65            rest = emptyList(),66        )6768    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */69    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =70        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7172    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =73        sequenceOf(url)74            .plus(additionalUrls.orEmpty().asSequence())75            .filterNotNull()76            .mapNotNull(::extractDomain)77            .any { it == originDomain }7879    /**80     * What a suggestion row or keyboard chip shows about an entry.81     * [iconSource] seeds the chip monogram; null keeps the Peach mark82     * (always the case in privacy mode — initials would leak titles too).83     */84    data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)8586    /**87     * Privacy previews: with [privatePreview] the rows and chips render only88     * the Peach brand and the caller's domain — no entry titles or usernames89     * in the IME's suggestion strip. The values still fill on tap.90     */91    fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =92        if (privatePreview) {93            PreviewLabels("Peach", originDomain ?: "", null)94        } else {95            PreviewLabels(96                entry.title,97                entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",98                entry.title,99            )100        }101102    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =103        RemoteViews(context.packageName, R.layout.autofill_row).apply {104            setTextViewText(R.id.autofill_row_title, labels.title)105            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)106        }107108    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =109        RemoteViews(context.packageName, R.layout.autofill_row).apply {110            setTextViewText(R.id.autofill_row_title, title)111            setTextViewText(R.id.autofill_row_subtitle, subtitle)112        }113114    /**115     * Build a dataset filling the parsed fields from [entry]. Attaches the inline116     * chip when the IME offered a spec (API 30+); otherwise menu-only.117     */118    fun datasetFor(119        context: Context,120        parsed: ParsedStructure,121        entry: VaultEntry,122        inlineSpec: android.widget.inline.InlinePresentationSpec?,123        privatePreview: Boolean,124    ): Dataset? {125        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))126        val menu = rowFor(context, labels)127        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {128            buildInline(context, labels, 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        }145        put(parsed.identityId, entry.username ?: entry.email)146        put(parsed.passwordId, entry.password)147        val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }148        put(parsed.otpId, otpCode)149        // A6: split single-character OTP boxes — one character per box150        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.151        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {152            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->153                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)154            }155            filledAny = true156        }157158        return if (filledAny) builder.build() else null159    }160161    /**162     * Build a dataset filling detected card fields from a card [entry].163     * Cards are not domain-matched — any detected card form offers all cards.164     */165    fun cardDatasetFor(166        context: Context,167        parsed: ParsedStructure,168        entry: VaultEntry,169        inlineSpec: android.widget.inline.InlinePresentationSpec?,170        privatePreview: Boolean,171    ): Dataset? {172        if (!entry.isCard()) return null173        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))174        val menu = rowFor(context, labels)175        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {176            buildInline(context, labels, inlineSpec)177        } else {178            null179        }180181        val builder = Dataset.Builder()182        var filledAny = false183        fun put(id: AutofillId?, value: String?) {184            if (id == null || value.isNullOrEmpty()) return185            val av = AutofillValue.forText(value)186            if (inline != null && Build.VERSION.SDK_INT >= 30) {187                builder.setValue(id, av, menu, inline)188            } else {189                builder.setValue(id, av, menu)190            }191            filledAny = true192        }193194        val digits = entry.cardNumber?.filter { it.isDigit() }195        put(parsed.cardNumberId, digits)196        put(parsed.cardExpMonthId, entry.expMonth)197        put(parsed.cardExpYearId, entry.expYear)198        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {199            val yy = entry.expYear.takeLast(2)200            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")201        }202        put(parsed.cardCvcId, entry.cvc)203        put(parsed.cardNameId, entry.cardholderName)204205        return if (filledAny) builder.build() else null206    }207208    /**209     * A "generate strong password" dataset for signup / no-match forms. Fills the210     * detected password field with a freshly generated password sized to the211     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends212     * up in the password field, so the generated value is offered for save like213     * any other fill. Menu-only (no inline chip) — there is no vault entry to214     * render. Parity for the browser's inline generator on create forms.215     */216    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {217        val passwordId = parsed.passwordId ?: return null218        val policy = parsed.passwordPolicy219        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH220        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))221        val note = policy?.constraintNote(length).orEmpty()222        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })223        return Dataset.Builder()224            .setValue(passwordId, AutofillValue.forText(password), menu)225            .build()226    }227228    /**229     * A4 identity helper: a dataset filling ONLY the signup identity field.230     * Email identities prefer the email field, usernames the username field;231     * each falls back to the other when its preferred field is absent.232     */233    fun identityDatasetFor(234        context: Context,235        parsed: ParsedStructure,236        identity: SavedIdentity,237    ): Dataset? {238        val targetId = when (identity.type) {239            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId240            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId241        } ?: return null242        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"243        val menu = simpleRow(context, identity.value, subtitle)244        return Dataset.Builder()245            .setValue(targetId, AutofillValue.forText(identity.value), menu)246            .build()247    }248249    /**250     * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site"251     * picker chip. The inline chip carries a [PendingIntent] that opens the252     * picker activity directly on tap (system menu row uses [setAuthentication]253     * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect).254     *255     * Without an attached [InlinePresentation] the dataset would render only256     * in the drop-down menu — the keyboard-row strip would never show the257     * picker, which was the v2.14.1 regression June hit.258     */259    fun searchPeachDataset(260        context: Context,261        parsed: ParsedStructure,262        inlineSpec: android.widget.inline.InlinePresentationSpec?,263        unlocked: Boolean,264        hasMatches: Boolean,265    ): Dataset {266        val labels = if (unlocked) {267            PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)268        } else {269            PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)270        }271        val menu = simpleRow(context, labels.title, labels.subtitle)272        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {273            buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))274        } else {275            null276        }277        val builder = Dataset.Builder(menu)278        (listOfNotNull(279            parsed.identityId, parsed.passwordId, parsed.otpId,280            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,281            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,282        ) + parsed.otpDigitBoxIds).forEach { id ->283            if (inline != null && Build.VERSION.SDK_INT >= 30) {284                builder.setValue(id, null, menu, inline)285            } else {286                builder.setValue(id, null, menu)287            }288        }289        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))290        return builder.build()291    }292293    /**294     * The "+ Save to Peach" create chip: tapping the chip launches MainActivity295     * on the new-entry editor pre-filled with the typed identity and origin.296     * F3 single-use token ([AutofillIntents.newEntryChipPendingIntent]) gates297     * the launch — MainActivity is exported.298     *299     * Gated to unlocked vaults: a locked vault cannot write a new entry, and300     * bouncing through MainActivity's unlock flow from an autofill chip is a301     * worse experience than waiting for the picker to unlock in-process.302     */303    fun createNewEntryDataset(304        context: Context,305        parsed: ParsedStructure,306        inlineSpec: android.widget.inline.InlinePresentationSpec?,307        unlocked: Boolean,308        origin: String?,309    ): Dataset? {310        if (!unlocked) return null311        val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null)312        val menu = simpleRow(context, labels.title, labels.subtitle)313        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {314            buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin))315        } else {316            null317        }318        val builder = Dataset.Builder(menu)319        // Drop a placeholder for every fillable id so the dataset is well-formed320        // even though no real value is provided — the chip's PendingIntent is the321        // user-facing affordance, the dataset just makes the chip eligible.322        (listOfNotNull(323            parsed.identityId, parsed.passwordId, parsed.otpId,324            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,325            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,326        ) + parsed.otpDigitBoxIds).forEach { id ->327            if (inline != null && Build.VERSION.SDK_INT >= 30) {328                builder.setValue(id, null, menu, inline)329            } else {330                builder.setValue(id, null, menu)331            }332        }333        return builder.build()334    }335336    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but337    // reading the slice to build an InlinePresentation is the documented usage.338    @SuppressLint("RestrictedApi")339    @RequiresApi(30)340    private fun buildInline(341        context: Context,342        labels: PreviewLabels,343        spec: android.widget.inline.InlinePresentationSpec,344        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),345    ): InlinePresentation? = try {346        val style = spec.style347        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {348            null349        } else {350            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }351                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)352            val slice = InlineSuggestionUi.newContentBuilder(chipPending)353                .setTitle(labels.title)354                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }355                .setStartIcon(startIcon)356                .build()357                .slice358            InlinePresentation(slice, spec, false)359        }360    } catch (_: Throwable) {361        // A non-conforming IME spec must never crash the fill — degrade to menu.362        null363    }364}