b47816e5c29e

fix: Search Peach + + Save to Peach inline chips never rendered (v2.14.2)

time
2026-08-05 05:09 UTC · 2 months ago
machine
koh2
parent
02ccf7aeb99c
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+60 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+90 −2app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+60 −45app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+20 −4app/src/test/java/com/peachpasswords/autofill/OtpDigitsTest.kt+14 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.11+2.14.2

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+60 −0
@@ -56,6 +56,8 @@5656 5757     private const val REQ_CHIP = 0xC0DE5858     private const val REQ_PICKER = 0xBEEF59+    private const val REQ_PICKER_CHIP = 0xBEE060+    private const val REQ_NEW_ENTRY_CHIP = 0xBEE15961 6062     @Volatile private var pendingNewEntryToken: String? = null6163 @@ -81,6 +83,64 @@8183     fun noopChipIntent(context: Context): PendingIntent {8284         val intent = Intent(context, AutofillEntryPickerActivity::class.java)8385         return PendingIntent.getActivity(context, REQ_CHIP, intent, immutableFlags())86+    }87+88+    /**89+     * Inline chip PendingIntent for the "Search Peach…" picker chip: tapping it opens90+     * the picker activity with the parsed structure so the user can browse the91+     * full vault. The system menu row for the same dataset uses [pickerIntentSender]92+     * (an IntentSender routed through the framework auth path).93+     */94+    fun pickerChipPendingIntent(context: Context, parsed: ParsedStructure): PendingIntent {95+        val intent = Intent(context, AutofillEntryPickerActivity::class.java).apply {96+            putExtra(EXTRA_IDENTITY_ID, parsed.identityId)97+            putExtra(EXTRA_PASSWORD_ID, parsed.passwordId)98+            putExtra(EXTRA_OTP_ID, parsed.otpId)99+            putParcelableArrayListExtra(EXTRA_OTP_DIGIT_BOX_IDS, ArrayList(parsed.otpDigitBoxIds))100+            putExtra(EXTRA_ORIGIN, parsed.origin)101+            putExtra(EXTRA_IS_WEB_ORIGIN, parsed.webDomain != null)102+            putExtra(EXTRA_CALLER_PACKAGE, parsed.packageName)103+            putExtra(EXTRA_CARD_NUMBER_ID, parsed.cardNumberId)104+            putExtra(EXTRA_CARD_EXP_MONTH_ID, parsed.cardExpMonthId)105+            putExtra(EXTRA_CARD_EXP_YEAR_ID, parsed.cardExpYearId)106+            putExtra(EXTRA_CARD_EXP_DATE_ID, parsed.cardExpDateId)107+            putExtra(EXTRA_CARD_CVC_ID, parsed.cardCvcId)108+            putExtra(EXTRA_CARD_NAME_ID, parsed.cardNameId)109+            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {110+                putExtra(EXTRA_TYPED_USERNAME, it)111+            }112+            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {113+                putExtra(EXTRA_TYPED_EMAIL, it)114+            }115+        }116+        return PendingIntent.getActivity(context, REQ_PICKER_CHIP, intent, immutableFlags())117+    }118+119+    /**120+     * Inline chip PendingIntent for the "+ Save to Peach" create chip: tapping it121+     * opens MainActivity on the new-entry editor pre-filled with the typed122+     * identity and origin. F3 single-use token gates the launch: MainActivity123+     * is exported, so without a fresh token any app could pre-fill the new-entry124+     * editor with attacker-chosen url/username/email.125+     */126+    fun newEntryChipPendingIntent(127+        context: Context,128+        parsed: ParsedStructure,129+        origin: String?,130+    ): PendingIntent {131+        val intent = Intent(context, com.peachpasswords.MainActivity::class.java).apply {132+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)133+            putExtra(EXTRA_OPEN_NEW_ENTRY, true)134+            putExtra(EXTRA_NEW_ENTRY_TOKEN, issueNewEntryToken())135+            origin?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_NEW_ENTRY_URL, it) }136+            parsed.usernameValue?.trim()?.takeIf { it.isNotBlank() }?.let {137+                putExtra(EXTRA_NEW_ENTRY_USERNAME, it)138+            }139+            parsed.emailValue?.trim()?.takeIf { it.isNotBlank() }?.let {140+                putExtra(EXTRA_NEW_ENTRY_EMAIL, it)141+            }142+        }143+        return PendingIntent.getActivity(context, REQ_NEW_ENTRY_CHIP, intent, immutableFlags())84144     }85145 86146     /** Authentication target for the "Search Peach…" dataset. */

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+90 −2
@@ -1,6 +1,7 @@11 package com.peachpasswords.autofill22 33 import android.annotation.SuppressLint4+import android.app.PendingIntent45 import android.content.Context56 import android.os.Build67 import android.service.autofill.Dataset@@ -245,6 +246,93 @@245246             .build()246247     }247248 249+    /**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+    }292+293+    /**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+    }335+248336     // InlineSuggestionUi.Content.getSlice is @RestrictedApi to androidx, but249337     // reading the slice to build an InlinePresentation is the documented usage.250338     @SuppressLint("RestrictedApi")@@ -253,15 +341,15 @@253341         context: Context,254342         labels: PreviewLabels,255343         spec: android.widget.inline.InlinePresentationSpec,344+        chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),256345     ): InlinePresentation? = try {257346         val style = spec.style258347         if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {259348             null260349         } else {261-            val pending = AutofillIntents.noopChipIntent(context)262350             val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }263351                 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)264-            val slice = InlineSuggestionUi.newContentBuilder(pending)352+            val slice = InlineSuggestionUi.newContentBuilder(chipPending)265353                 .setTitle(labels.title)266354                 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }267355                 .setStartIcon(startIcon)

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+60 −45
@@ -48,20 +48,31 @@4848 4949         val inlineSpecs = inlineSpecs(request)5050         val privatePreview = vault.settings.value.hideAutofillPreviews51+        val unlocked = vault.isUnlocked.value5152         val response = FillResponse.Builder()5253         var datasetCount = 05354         var hasLoginMatch = false54-        // Reserve the last inline slot for the branded picker chip so the full55-        // vault stays reachable from the keyboard row even when entry chips56-        // fill every other slot.57-        val pickerSpec = inlineSpecs.lastOrNull()58-        val entryChipBudget = inlineChipBudget(inlineSpecs.size)55+        // Reserve the last inline slots for the system chips (picker + create)56+        // so the full vault stays reachable from the keyboard row even when57+        // entry chips fill every other slot. The IME scrolls horizontally for58+        // overflow on wide enough strips; on narrow strips the user can still59+        // reach every matching entry via the system drop-down menu.60+        val systemChipCount = inlineSystemChipCount(inlineSpecs.size)61+        val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)62+        val pickerSpec = if (inlineSpecs.size >= 2) {63+            inlineSpecs[inlineSpecs.size - 2]64+        } else {65+            inlineSpecs.lastOrNull()66+        }67+        val createSpec = inlineSpecs.lastOrNull()5968         var entryChips = 06069 61-        if (vault.isUnlocked.value) {62-            val matches = AutofillResponseBuilder63-                .matchEntries(vault.getEntries(), parsed)64-                .all.take(MAX_DATASETS)70+        if (unlocked) {71+            // No artificial cap on matching entries: each entry gets its own72+            // inline spec slot up to the budget; the IME renders what fits and73+            // scrolls the rest. The system drop-down menu still lists every74+            // matching entry, so the user is never locked out.75+            val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all6576             matches.forEach { entry ->6677                 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null6778                 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {@@ -72,8 +83,8 @@7283             }7384         }7485 75-        if (parsed.hasCardField && vault.isUnlocked.value) {76-            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEach { entry ->86+        if (parsed.hasCardField && unlocked) {87+            vault.getEntries().filter { it.isCard() }.forEach { entry ->7788                 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null7889                 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {7990                     response.addDataset(it)@@ -86,7 +97,7 @@8697         // field) or any password form with no matching login, sized to the site's8798         // detected policy. Gated to no-match so it never appears over a known8899         // login the user is about to fill.89-        if (vault.isUnlocked.value && parsed.passwordId != null &&100+        if (unlocked && parsed.passwordId != null &&90101             (parsed.isNewPassword || !hasLoginMatch)91102         ) {92103             runCatching {@@ -97,15 +108,32 @@97108             }98109         }99110 100-        // Always offer the branded picker as the last row (search / unlock / no-match).101-        val pickerLabel = autofillPickerLabel(102-            unlocked = vault.isUnlocked.value,103-            datasetCount = datasetCount,104-        )111+        // Always offer the branded picker as a system chip (search / unlock /112+        // no-match) and the "+ Save to Peach" create chip when the vault is113+        // unlocked. Both chips live in the reserved last inline slots; the114+        // drop-down menu still lists them when the IME offered no specs.105115         runCatching {106-            response.addDataset(pickerDataset(context, parsed, pickerLabel, pickerSpec))116+            response.addDataset(117+                AutofillResponseBuilder.searchPeachDataset(118+                    context = context,119+                    parsed = parsed,120+                    inlineSpec = pickerSpec,121+                    unlocked = unlocked,122+                    hasMatches = hasLoginMatch,123+                )124+            )107125             datasetCount++108126         }127+        AutofillResponseBuilder.createNewEntryDataset(128+            context = context,129+            parsed = parsed,130+            inlineSpec = createSpec,131+            unlocked = unlocked,132+            origin = parsed.origin,133+        )?.let {134+            response.addDataset(it)135+            datasetCount++136+        }109137 110138         if (datasetCount == 0) {111139             callback.onSuccess(null)@@ -116,28 +144,6 @@116144         }117145     }118146 119-    private fun pickerDataset(120-        context: android.content.Context,121-        parsed: ParsedStructure,122-        label: String,123-        inlineSpec: android.widget.inline.InlinePresentationSpec?,124-    ): android.service.autofill.Dataset {125-        val subtitle = parsed.origin.orEmpty()126-        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)127-        val builder = android.service.autofill.Dataset.Builder(menu)128-        // A placeholder value per field keeps the dataset valid; the real values129-        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.130-        (listOfNotNull(131-            parsed.identityId, parsed.passwordId, parsed.otpId,132-            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,133-            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,134-        ) + parsed.otpDigitBoxIds).forEach { id ->135-            builder.setValue(id, null, menu)136-        }137-        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))138-        return builder.build()139-    }140-141147     /** Mark detected ids the framework should track even when we filled nothing. */142148     private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {143149         val ids = listOfNotNull(@@ -200,13 +206,22 @@200206         super.onDestroy()201207     }202208 203-    private companion object {204-        const val MAX_DATASETS = 6205-    }206209 }207210 208-/** Entry/card chips get every inline slot except the one reserved for the picker. */209-internal fun inlineChipBudget(totalSpecs: Int): Int = if (totalSpecs > 0) totalSpecs - 1 else 0211+/**212+ * Number of inline slots reserved for system chips (Search Peach / + Save to213+ * Peach). With 2+ available slots we keep one each; with a single slot the214+ * picker wins (create gets the drop-down row but no chip), since unlocking the215+ * picker is the more common next action than creating a new entry.216+ */217+internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0218+219+/**220+ * Entry/card chips get every inline slot not reserved for system chips.221+ * Preserved for the existing unit test — the live service uses222+ * [inlineSystemChipCount] directly.223+ */224+internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)210225 211226 internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {212227     !unlocked -> "Unlock Peach"

view file

app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+20 −4
@@ -6,14 +6,30 @@66 class AutofillChipBudgetTest {77     @Test fun no_specs_means_no_entry_chips() {88         assertEquals(0, inlineChipBudget(0))9+        assertEquals(0, inlineSystemChipCount(0))910     }1011 11-    @Test fun one_spec_is_fully_reserved_for_the_picker() {12+    @Test fun one_spec_reserves_it_for_the_picker_only() {13+        // Single spec: picker wins, create degrades to drop-down row only.1214         assertEquals(0, inlineChipBudget(1))15+        assertEquals(1, inlineSystemChipCount(1))1316     }1417 15-    @Test fun picker_always_keeps_the_last_slot() {16-        assertEquals(1, inlineChipBudget(2))17-        assertEquals(2, inlineChipBudget(3))18+    @Test fun two_or_more_specs_reserve_two_system_chips() {19+        // With 2+ specs: picker at -2, create at -1, the rest go to entries.20+        assertEquals(2, inlineSystemChipCount(2))21+        assertEquals(2, inlineSystemChipCount(3))22+        assertEquals(2, inlineSystemChipCount(5))23+        assertEquals(2, inlineSystemChipCount(9))24+    }25+26+    @Test fun entries_get_every_slot_not_reserved_for_system_chips() {27+        assertEquals(0, inlineChipBudget(0))28+        assertEquals(0, inlineChipBudget(1))29+        assertEquals(0, inlineChipBudget(2))30+        assertEquals(1, inlineChipBudget(3))31+        assertEquals(2, inlineChipBudget(4))32+        assertEquals(3, inlineChipBudget(5))33+        assertEquals(7, inlineChipBudget(9))1834     }1935 }

view file

app/src/test/java/com/peachpasswords/autofill/OtpDigitsTest.kt+14 −0
@@ -26,4 +26,18 @@2626     @Test fun strips_whitespace_before_distributing() {2727         assertEquals(listOf("1", "2", "3"), AutofillResponseBuilder.otpDigitsFor(6, "1 2 3"))2828     }29+30+    @Test fun six_boxes_map_to_a_full_six_digit_code() {31+        // Common 2FA split layout: 6 single-character boxes for a 6-digit TOTP.32+        // Mirrors the inline chip path where [parsed.otpDigitBoxIds] is six33+        // AutofillIds and the entry's TOTP seed generates a fresh code.34+        val boxes = AutofillResponseBuilder.otpDigitsFor(6, "481208")35+        assertEquals(listOf("4", "8", "1", "2", "0", "8"), boxes)36+    }37+38+    @Test fun empty_code_produces_empty_boxes() {39+        // Edge case: the entry has no TOTP seed and generateCode returned null;40+        // otpDigitsFor must not crash when there is no code to distribute.41+        assertEquals(emptyList<String>(), AutofillResponseBuilder.otpDigitsFor(6, ""))42+    }2943 }

view file