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
↓ peach-android-b47816e5.tar.gzatlas at this save
files changed6 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 −0app/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. */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)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"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 }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 }