AutofillResponseBuilder.kt

23.2 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 android.graphics.Bitmap14import android.graphics.Canvas15import android.graphics.Paint16import android.graphics.drawable.Icon17import android.util.LruCache18import androidx.autofill.inline.v1.InlineSuggestionUi19import androidx.autofill.inline.UiVersions20import com.peachpasswords.R21import com.peachpasswords.shared.extractDomain22import com.peachpasswords.vault.PasswordGenerator23import com.peachpasswords.vault.TOTPGenerator24import com.peachpasswords.vault.VaultEntry25import com.peachpasswords.vault.isCard2627/**28 * Turns parsed form fields + vault entries into autofill [Dataset]s — the menu29 * RemoteViews row, the inline keyboard chip, and the actual field values.30 * TOTP is filled as a freshly-generated code; the seed never leaves the vault.31 */32// Dataset.setValue remains required through API 32; keep the compatibility33// path isolated until Peach's minimum SDK can use API 33 Field exclusively.34@Suppress("DEPRECATION")35object AutofillResponseBuilder {3637    /**38     * Registrable-domain matching. Missing, malformed, local, and IP origins39     * return no entries (there is nothing to rank against).40     */41    data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {42        val all: List<VaultEntry> get() = hits + rest43    }4445    fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {46        val originDomain = origin?.let(::extractDomain)47            ?: return MatchResult(emptyList(), emptyList())48        return matchDomains(entries, setOf(originDomain))49    }5051    /**52     * Rank entries for the already-resolved origin. The service verifies web53     * caller identity before populating web domains. Native callers match on54     * their verified app-link domains plus an explicit package-name55     * association — never on their display label, which any app can choose56     * freely (PAI-2026-001). Blank origins match nothing.57     */58    fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {59        val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits60        val byPackage = nativePackageMatches(entries, parsed)61        return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList())62    }6364    /**65     * An entry the user associated with this exact package — the save paths66     * store the calling package as the entry's url. Exact match, not domain67     * matching: two packages can share a registrable domain under the public68     * suffix list (com.a.example.app and com.b.example.app both reduce to69     * example.app), so anything weaker would re-open the confusion the label70     * match had.71     */72    private fun nativePackageMatches(entries: List<VaultEntry>, parsed: ParsedStructure): List<VaultEntry> {73        val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList()74        if (parsed.webDomain != null) return emptyList()75        val signerDigests = parsed.nativeSignerDigests76        if (signerDigests.isNullOrEmpty()) return emptyList()77        return entries.filter { entry ->78            (entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName }) &&79                entry.nativeSignerDigests.orEmpty().any { it in signerDigests }80        }81    }8283    private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =84        MatchResult(85            hits = entries.filter { entry ->86                domains.any { domain -> entry.matchesOriginDomain(domain) }87            },88            rest = emptyList(),89        )9091    /** Distribute a TOTP code character-by-character over [boxCount] split boxes. */92    internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =93        code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }9495    private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =96        sequenceOf(url)97            .plus(additionalUrls.orEmpty().asSequence())98            .filterNotNull()99            .mapNotNull(::extractDomain)100            .any { it == originDomain }101102    /**103     * What a suggestion row or keyboard chip shows about an entry.104     * [iconSource] seeds the chip monogram; null keeps the Peach mark105     * (always the case in privacy mode — initials would leak titles too).106     */107    enum class FillMode { LOGIN, OTP_FILL }108109    data class PreviewLabels(110        val title: String,111        val subtitle: String,112        val iconSource: String?,113        val fillMode: FillMode = FillMode.LOGIN,114        val endIcon: Icon? = null,115    )116117    /**118     * Privacy previews: with [privatePreview] the rows and chips render only119     * the Peach brand and the caller's domain — no entry titles or usernames120     * in the IME's suggestion strip. The values still fill on tap.121     */122    /**123     * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric124     * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits125     * pass through unchanged — a 7-digit code is rarely grouped, and stripping126     * letters to chunk the digits would mutilate the code. The autofilled value127     * stays the raw code so the form receives what its regex expects.128     */129    internal fun chunkOtpForDisplay(code: String): String {130        if (code.length != 6 || !code.all { it.isDigit() }) return code131        return code.chunked(3).joinToString(" ")132    }133134    /**135     * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when136     * the dataset fills ONLY an OTP field (no identity/password) so the chip137     * surfaces the actual TOTP code as the title and the literal "2FA" in the138     * subtitle. The entry's favicon/initial on the left already conveys the139     * entry, so a duplicate-name subtitle invites the user to misread the140     * digits as their password. Private preview always wins: digits are never141     * leaked into the chip strip even in this mode.142     */143    fun previewLabels(144        privatePreview: Boolean,145        entry: VaultEntry,146        originDomain: String?,147        fillMode: FillMode = FillMode.LOGIN,148        otpCode: String? = null,149        secondsRemaining: Int? = null,150    ): PreviewLabels = when {151        privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)152        fillMode == FillMode.OTP_FILL -> {153            val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }154            // Subtitle reads "2FA" instead of the entry title so the user can155            // tell a TOTP chip from a password chip at a glance — the entry's156            // favicon/initial on the left already conveys the entry, so a157            // duplicate-name subtitle invites the user to misread the digits158            // as their password. When [secondsRemaining] is known we159            // append the TOTP window countdown (the chip itself is static;160            // a fresh chip with a fresh code is rebuilt on every focus-change161            // FillRequest, so the user only needs to tap off and back into162            // the field to get the next code).163            val subtitle = when {164                secondsRemaining == null -> "2FA"165                secondsRemaining <= 0 -> "2FA · expired"166                else -> "2FA · ${secondsRemaining}s"167            }168            val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }169            PreviewLabels(170                title = chunkOtpForDisplay(code),171                subtitle = subtitle,172                iconSource = entry.title,173                fillMode = FillMode.OTP_FILL,174                endIcon = endIcon,175            )176        }177        else -> PreviewLabels(178            entry.title,179            entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",180            entry.title,181            FillMode.LOGIN,182        )183    }184185    fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =186        RemoteViews(context.packageName, R.layout.autofill_row).apply {187            setTextViewText(R.id.autofill_row_title, labels.title)188            setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)189        }190191    fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =192        RemoteViews(context.packageName, R.layout.autofill_row).apply {193            setTextViewText(R.id.autofill_row_title, title)194            setTextViewText(R.id.autofill_row_subtitle, subtitle)195        }196197    /**198     * Build a dataset filling the parsed fields from [entry]. Attaches the inline199     * chip when the IME offered a spec (API 30+); otherwise menu-only.200     */201    fun datasetFor(202        context: Context,203        parsed: ParsedStructure,204        entry: VaultEntry,205        inlineSpec: android.widget.inline.InlinePresentationSpec?,206        privatePreview: Boolean,207    ): Dataset? {208        // Generate the TOTP code once and route it to BOTH the chip title (when209        // the dataset fills ONLY an OTP field) and the fill value (always).210        // The FillMode.OTP_FILL chip surfaces the actual digits in the211        // keyboard strip so the user can confirm what they're about to type.212        val otp = entry.totp?.let { TOTPGenerator.generate(it) }213        val otpCode = otp?.code214        val fillingOnlyOtp = parsed.identityId == null &&215            parsed.passwordId == null &&216            parsed.otpId != null &&217            otpCode != null218        val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN219        val labels = previewLabels(220            privatePreview,221            entry,222            parsed.origin?.let(::extractDomain),223            fillMode = fillMode,224            otpCode = if (fillingOnlyOtp) otpCode else null,225            secondsRemaining = otp?.remaining,226        )227        val menu = rowFor(context, labels)228        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {229            buildInline(context, labels, inlineSpec)230        } else {231            null232        }233234        val builder = Dataset.Builder()235        var filledAny = false236        fun put(id: AutofillId?, value: String?) {237            if (id == null || value.isNullOrEmpty()) return238            val av = AutofillValue.forText(value)239            if (inline != null && Build.VERSION.SDK_INT >= 30) {240                builder.setValue(id, av, menu, inline)241            } else {242                builder.setValue(id, av, menu)243            }244            filledAny = true245        }246        put(parsed.identityId, entry.username ?: entry.email)247        put(parsed.passwordId, entry.password)248        // OTP fill on a multi-field form is handled by [otpDatasetFor] -- a249        // separate dataset with its own chip on the 2FA field. The LOGIN chip250        // intentionally does NOT fill otp here, so the user can verify the251        // code before it lands: "tap github fills email and password, then252        // automatically surfaces the next chip with 2FA code" (continuity253        // 2026-08-05). On an OTP-only form (fillingOnlyOtp) we keep filling254        // from the LOGIN path -- there's no separate chip to defer to.255        if (fillingOnlyOtp) put(parsed.otpId, otpCode)256        // A6: split single-character OTP boxes — one character per box257        // (browser's fillOTPDigitBoxes). Only when no full-code field exists.258        if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {259            otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->260                builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)261            }262            filledAny = true263        }264265        return if (filledAny) builder.build() else null266    }267268    /**269     * A second inline dataset for the 2FA chip: fills ONLY the detected OTP270     * field with a freshly generated TOTP code and renders its chip in271     * [FillMode.OTP_FILL] (title = chunked code, subtitle = "2FA"). The user272     * wanted the LOGIN chip and the 2FA chip as separate affordances:273     * "tap 'github' it fills email and password, then automatically surfaces274     * the next chip with 2FA code" (continuity 2026-08-05). The chip lands on275     * the spec whose autofillId matches [parsed.otpId]; if no spec matches276     * (the framework only offered a single spec on a focused 2FA field, say),277     * the dataset still surfaces in the system menu.278     */279    fun otpDatasetFor(280        context: Context,281        parsed: ParsedStructure,282        entry: VaultEntry,283        inlineSpec: android.widget.inline.InlinePresentationSpec?,284        privatePreview: Boolean,285    ): Dataset? {286        val otpId = parsed.otpId ?: return null287        val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null288        val otpCode = otp.code289        val labels = previewLabels(290            privatePreview = privatePreview,291            entry = entry,292            originDomain = parsed.origin?.let(::extractDomain),293            fillMode = FillMode.OTP_FILL,294            otpCode = otpCode,295            secondsRemaining = otp.remaining,296        )297        val menu = rowFor(context, labels)298        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {299            buildInline(context, labels, inlineSpec)300        } else {301            null302        }303        val builder = Dataset.Builder()304        val av = AutofillValue.forText(otpCode)305        if (inline != null && Build.VERSION.SDK_INT >= 30) {306            builder.setValue(otpId, av, menu, inline)307        } else {308            builder.setValue(otpId, av, menu)309        }310        return builder.build()311    }312313    /**314     * Build a dataset filling detected card fields from a card [entry].315     * Cards are not domain-matched — any detected card form offers all cards.316     */317    fun cardDatasetFor(318        context: Context,319        parsed: ParsedStructure,320        entry: VaultEntry,321        inlineSpec: android.widget.inline.InlinePresentationSpec?,322        privatePreview: Boolean,323    ): Dataset? {324        if (!entry.isCard()) return null325        val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))326        val menu = rowFor(context, labels)327        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {328            buildInline(context, labels, inlineSpec)329        } else {330            null331        }332333        val builder = Dataset.Builder()334        var filledAny = false335        fun put(id: AutofillId?, value: String?) {336            if (id == null || value.isNullOrEmpty()) return337            val av = AutofillValue.forText(value)338            if (inline != null && Build.VERSION.SDK_INT >= 30) {339                builder.setValue(id, av, menu, inline)340            } else {341                builder.setValue(id, av, menu)342            }343            filledAny = true344        }345346        val digits = entry.cardNumber?.filter { it.isDigit() }347        put(parsed.cardNumberId, digits)348        put(parsed.cardExpMonthId, entry.expMonth)349        put(parsed.cardExpYearId, entry.expYear)350        if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {351            val yy = entry.expYear.takeLast(2)352            put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")353        }354        put(parsed.cardCvcId, entry.cvc)355        put(parsed.cardNameId, entry.cardholderName)356357        return if (filledAny) builder.build() else null358    }359360    /**361     * A "generate strong password" dataset for signup / no-match forms. Fills the362     * detected password field with a freshly generated password sized to the363     * site's [PasswordPolicy] (A3). The standard save flow captures whatever ends364     * up in the password field, so the generated value is offered for save like365     * any other fill. Menu-only (no inline chip) — there is no vault entry to366     * render. Parity for the browser's inline generator on create forms.367     */368    fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {369        val passwordId = parsed.passwordId ?: return null370        val policy = parsed.passwordPolicy371        val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH372        val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))373        val note = policy?.constraintNote(length).orEmpty()374        val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })375        return Dataset.Builder()376            .setValue(passwordId, AutofillValue.forText(password), menu)377            .build()378    }379380    /**381     * A4 identity helper: a dataset filling ONLY the signup identity field.382     * Email identities prefer the email field, usernames the username field;383     * each falls back to the other when its preferred field is absent.384     */385    fun identityDatasetFor(386        context: Context,387        parsed: ParsedStructure,388        identity: SavedIdentity,389    ): Dataset? {390        val targetId = when (identity.type) {391            IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId392            IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId393        } ?: return null394        val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"395        val menu = simpleRow(context, identity.value, subtitle)396        return Dataset.Builder()397            .setValue(targetId, AutofillValue.forText(identity.value), menu)398            .build()399    }400401    /**402     * The "Search Peach…" / "Unlock Peach" / "No saved logins for this site"403     * picker chip. The inline chip carries a [PendingIntent] that opens the404     * picker activity directly on tap (system menu row uses [setAuthentication]405     * via [PeachAutofillService.searchPeachDataset (via AutofillResponseBuilder)] for the same effect).406     *407     * Without an attached [InlinePresentation] the dataset would render only408     * in the drop-down menu — the keyboard-row strip would never show the409     * picker, which was the v2.14.1 regression June hit.410     */411    fun searchPeachDataset(412        context: Context,413        parsed: ParsedStructure,414        inlineSpec: android.widget.inline.InlinePresentationSpec?,415        unlocked: Boolean,416        hasMatches: Boolean,417        requestId: Int,418    ): Dataset {419        val labels = if (unlocked) {420            PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)421        } else {422            PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)423        }424        val menu = simpleRow(context, labels.title, labels.subtitle)425        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {426            buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed, requestId))427        } else {428            null429        }430        val builder = Dataset.Builder(menu)431        val fieldIds = listOfNotNull(432            parsed.identityId, parsed.passwordId, parsed.otpId,433            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,434            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,435        ) + parsed.otpDigitBoxIds436        if (inline != null && Build.VERSION.SDK_INT >= 30) {437            // Samsung's inline renderer drops authenticated datasets whose only438            // field values are null. Bind the chip to one credential field with439            // an empty value; authentication still supplies the real dataset.440            fieldIds.firstOrNull()?.let { id ->441                builder.setValue(id, AutofillValue.forText(""), menu, inline)442            }443            fieldIds.drop(1).forEach { id -> builder.setValue(id, null, menu) }444        } else {445            fieldIds.forEach { id -> builder.setValue(id, null, menu) }446        }447        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed, requestId))448        return builder.build()449    }450451    // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but452    // reading the slice to build an InlinePresentation is the documented usage.453    @SuppressLint("RestrictedApi")454    @RequiresApi(30)455    private fun buildInline(456        context: Context,457        labels: PreviewLabels,458        spec: android.widget.inline.InlinePresentationSpec,459        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context, 0),460    ): InlinePresentation? = try {461        val style = spec.style462        if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {463            null464        } else {465            val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }466                ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)467            val slice = InlineSuggestionUi.newContentBuilder(chipPending)468                .setTitle(labels.title)469                .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }470                .setStartIcon(startIcon)471                .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }472                .build()473                .slice474            InlinePresentation(slice, spec, false)475        }476    } catch (_: Throwable) {477        // A non-conforming IME spec must never crash the fill — degrade to menu.478        null479    }480481    /**482     * Small colored dot rendered as the inline chip's end icon when the 2FA483     * chip is in [FillMode.OTP_FILL] with a known [secondsRemaining].484     *485     * Colors match the urgency: green (>= 10s), amber (5..9s), red (<= 4s486     * or already expired). The chip itself is a static RemoteViews render487     * — once built, no live animation runs; a fresh chip with a fresh488     * code is returned on the next focus-change FillRequest, so the489     * user only needs to tap off the keyboard and back into the 2FA490     * field to get the next code. Cached by color so per-fill alloc is491     * constant.492     */493    private object OtpCountdownDot {494        private const val SIZE_PX = 32495        private const val GREEN = 0xFF6FB07F.toInt()496        private const val AMBER = 0xFFE0A857.toInt()497        private const val RED = 0xFFD8534B.toInt()498        private val cache = LruCache<Int, Icon>(8)499500        fun icon(secondsRemaining: Int): Icon? {501            val color = when {502                secondsRemaining <= 4 -> RED503                secondsRemaining <= 9 -> AMBER504                else -> GREEN505            }506            cache.get(color)?.let { return it }507            // Bitmap creation is a no-op on the JVM test runtime (the508            // android.jar stubs return null for createBitmap). Fail-soft509            // there — the chip still gets the seconds-left text; the510            // colored dot only matters on a real device where the dot511            // is also the glanceable expiry signal the user asked for.512            val icon = runCatching {513                val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)514                val canvas = Canvas(bm)515                val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }516                canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)517                Icon.createWithBitmap(bm)518            }.getOrNull() ?: return null519            cache.put(color, icon)520            return icon521        }522    }523}