ba1cd3cf8da3
feat: chip-strip slot refinement after create-chip removal + dead-code cleanup (v2.14.8)
- time
- 2026-08-05 18:52 UTC · 2 months ago
- machine
- koh2
- parent
- 33d29cc2981c
↓ peach-android-ba1cd3cf.tar.gzatlas at this save
files changed5 files
VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+0 −28app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+48 −50app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+31 −23app/src/test/java/com/peachpasswords/autofill/AutofillPickerSpecSelectionTest.kt+29 −15app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+0 −28
@@ -57,7 +57,6 @@5757 private const val REQ_CHIP = 0xC0DE5858 private const val REQ_PICKER = 0xBEEF5959 private const val REQ_PICKER_CHIP = 0xBEE060- private const val REQ_NEW_ENTRY_CHIP = 0xBEE16160 6261 @Volatile private var pendingNewEntryToken: String? = null6362 @@ -114,33 +113,6 @@114113 }115114 }116115 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())144116 }145117 146118 /** Authentication target for the "Search Peach…" dataset. */app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+48 −50
@@ -60,35 +60,32 @@6060 val inlineSpecs = inlineSpecs(request)6161 val privatePreview = vault.settings.value.hideAutofillPreviews6262 val unlocked = vault.isUnlocked.value63+ val matches = if (unlocked) {64+ AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all65+ } else {66+ emptyList()67+ }6368 val response = FillResponse.Builder()6469 var datasetCount = 06570 var hasLoginMatch = false66- // Reserve the last inline slots for the system chips (picker + create)67- // so the full vault stays reachable from the keyboard row even when68- // entry chips fill every other slot. The IME scrolls horizontally for69- // overflow on wide enough strips; on narrow strips the user can still70- // reach every matching entry via the system drop-down menu.71- val systemChipCount = inlineSystemChipCount(inlineSpecs.size)72- val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)73- // v2.14.6: when only one inline spec is offered by the framework74- // (common on 2FA-only pages after username/password submit), the75- // picker spec and OTP spec would both target specs[0] and the picker76- // dataset -- added after the OTP dataset -- would clobber the OTP77- // chip inline. Drop the picker to the system menu in the single-spec78- // case so the 2FA chip has the slot to itself. The picker activity79- // remains reachable via the keyboard overflow menu.80- val pickerSpec = pickerSpecFor(inlineSpecs)81- // OTP chip takes the last inline spec slot (which is conventionally82- // the 2FA field on a username/password/2FA form -- the framework83- // sends specs in field-order). InlinePresentationSpec doesn't carry84- // an autofillId so we can't pin it to the exact field; the85- // last-slot heuristic is the closest mapping the framework offers.86- // (v2.14.6) The "+ Save to Peach" create chip is gone -- the chip's87- // PendingIntent was unreliable after unlock and never reached the88- // new-entry editor. The picker activity still exposes an explicit89- // "Add new entry" action, so creation remains reachable.90- val otpEligible = parsed.otpId != null &&91- vault.getEntries().any { !it.totp.isNullOrBlank() }71+ // v2.14.8 chip-strip refinement: since v2.14.6 removed the "+ Save to72+ // Peach" create chip, only the picker and (when eligible) the 2FA chip73+ // need reserved slots. The OTP chip keeps the last inline spec slot74+ // (the framework sends specs in field-order, so that is conventionally75+ // the 2FA field on a username/password/2FA form; InlinePresentationSpec76+ // carries no autofillId, so the last-slot heuristic is the closest77+ // mapping the framework offers), and the picker takes the slot just78+ // before it -- or the last slot itself when there is no OTP chip.79+ // Every remaining slot becomes an entry chip, so a wide strip fills80+ // completely and scrolls, and a narrow strip still lists everything81+ // in the system drop-down menu. Gating otpEligible on MATCHED entries82+ // (not any vault entry) stops a TOTP-less match set from orphaning83+ // the last slot. v2.14.6 single-spec collision fix holds: with one84+ // spec and an OTP chip eligible, the picker drops to the system menu85+ // so the 2FA chip keeps the lone slot.86+ val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }87+ val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)88+ val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)9289 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null9390 var entryChips = 09491 @@ -97,7 +94,6 @@9794 // inline spec slot up to the budget; the IME renders what fits and9895 // scrolls the rest. The system drop-down menu still lists every9996 // matching entry, so the user is never locked out.100- val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all10197 matches.forEach { entry ->10298 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null10399 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {@@ -168,9 +164,8 @@168164 }169165 170166 // Always offer the branded picker as a system chip (search / unlock /171- // no-match) and the "+ Save to Peach" create chip when the vault is172- // unlocked. Both chips live in the reserved last inline slots; the173- // drop-down menu still lists them when the IME offered no specs.167+ // no-match). The chip lives in the reserved inline slot; the drop-down168+ // menu still lists it when the IME offered no specs.174169 runCatching {175170 response.addDataset(176171 AutofillResponseBuilder.searchPeachDataset(@@ -257,19 +252,17 @@257252 }258253 259254 /**260- * Number of inline slots reserved for system chips (Search Peach / + Save to261- * Peach). With 2+ available slots we keep one each; with a single slot the262- * picker wins (create gets the drop-down row but no chip), since unlocking the263- * picker is the more common next action than creating a new entry.255+ * Number of inline slots reserved for system chips: one for the "Search256+ * Peach" picker whenever any spec exists, plus one more for the dedicated257+ * 2FA chip when eligible. v2.14.6 removed the "+ Save to Peach" create chip;258+ * v2.14.8 hands its leftover reservation back to entry chips.264259 */265-internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0260+internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =261+ if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1266262 267-/**268- * Entry/card chips get every inline slot not reserved for system chips.269- * Preserved for the existing unit test — the live service uses270- * [inlineSystemChipCount] directly.271- */272-internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)263+/** Entry/card chips get every inline slot not reserved for system chips. */264+internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =265+ (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)273266 274267 internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {275268 !unlocked -> "Unlock Peach"@@ -279,13 +272,18 @@279272 280273 281274 /**282- * v2.14.6: pick the inline spec that hosts the "Search Peach…" chip, or283- * null when there isn't room. With 2+ specs the picker takes the slot just284- * before the OTP chip (which lives at the last spec); with a single spec285- * the picker falls back to the system menu so the OTP chip keeps the lone286- * inline slot. The picker activity is always reachable from the keyboard287- * overflow menu, so dropping the inline chip in the single-spec case288- * doesn't lock the user out.275+ * Pick the inline spec that hosts the "Search Peach…" chip, or null when276+ * there isn't room. With an OTP chip eligible the picker takes the slot just277+ * before it (the OTP chip owns the last spec); with a single spec the picker278+ * falls back to the system menu so the 2FA chip keeps the lone inline slot279+ * (v2.14.6 collision fix). Without an OTP chip the picker anchors the last280+ * slot itself (v2.14.8 -- before, the stale two-slot reservation left the281+ * last slot empty). The picker activity is always reachable from the282+ * keyboard overflow menu, so dropping the inline chip never locks the283+ * user out.289284 */290-internal fun <T> pickerSpecFor(specs: List<T>): T? =291- if (specs.size >= 2) specs[specs.size - 2] else null285+internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {286+ specs.isEmpty() -> null287+ otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null288+ else -> specs.last()289+}app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+31 −23
@@ -3,33 +3,41 @@33 import org.junit.Assert.assertEquals44 import org.junit.Test55 6+/**7+ * Chip-slot budget after the v2.14.8 refinement. The "+ Save to Peach" create8+ * chip was removed in v2.14.6, but its reserved inline slot lingered -- with9+ * 2 specs and no 2FA chip the user saw ONLY the "Search Peach" chip and an10+ * empty slot. Now the picker reserves one slot (plus one more for the 2FA11+ * chip when eligible) and entries get everything else.12+ */613 class AutofillChipBudgetTest {7- @Test fun no_specs_means_no_entry_chips() {8- assertEquals(0, inlineChipBudget(0))9- assertEquals(0, inlineSystemChipCount(0))14+ @Test fun no_specs_means_no_chips() {15+ assertEquals(0, inlineSystemChipCount(0, otpEligible = false))16+ assertEquals(0, inlineSystemChipCount(0, otpEligible = true))17+ assertEquals(0, inlineChipBudget(0, otpEligible = false))18+ assertEquals(0, inlineChipBudget(0, otpEligible = true))1019 }1120 12- @Test fun one_spec_reserves_it_for_the_picker_only() {13- // Single spec: picker wins, create degrades to drop-down row only.14- assertEquals(0, inlineChipBudget(1))15- assertEquals(1, inlineSystemChipCount(1))21+ @Test fun without_otp_only_the_picker_slot_is_reserved() {22+ assertEquals(1, inlineSystemChipCount(1, otpEligible = false))23+ assertEquals(1, inlineSystemChipCount(2, otpEligible = false))24+ assertEquals(1, inlineSystemChipCount(5, otpEligible = false))25+ assertEquals(1, inlineSystemChipCount(9, otpEligible = false))26+ // Every non-reserved slot goes to entry chips.27+ assertEquals(0, inlineChipBudget(1, otpEligible = false))28+ assertEquals(1, inlineChipBudget(2, otpEligible = false))29+ assertEquals(2, inlineChipBudget(3, otpEligible = false))30+ assertEquals(4, inlineChipBudget(5, otpEligible = false))31+ assertEquals(8, inlineChipBudget(9, otpEligible = false))1632 }1733 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))34+ @Test fun with_otp_the_picker_and_2fa_chip_are_reserved() {35+ assertEquals(2, inlineSystemChipCount(2, otpEligible = true))36+ assertEquals(2, inlineSystemChipCount(3, otpEligible = true))37+ assertEquals(2, inlineSystemChipCount(9, otpEligible = true))38+ assertEquals(0, inlineChipBudget(1, otpEligible = true))39+ assertEquals(0, inlineChipBudget(2, otpEligible = true))40+ assertEquals(1, inlineChipBudget(3, otpEligible = true))41+ assertEquals(7, inlineChipBudget(9, otpEligible = true))3442 }3543 }app/src/test/java/com/peachpasswords/autofill/AutofillPickerSpecSelectionTest.kt+29 −15
@@ -5,35 +5,49 @@55 import org.junit.Test66 77 /**8- * v2.14.6 regression: with only one inline spec offered by the framework9- * (common on 2FA-only pages after username/password submit), the picker10- * spec and OTP spec would both target specs[0] and the picker dataset,11- * being added last, would clobber the OTP chip inline. The picker now12- * falls back to the system menu in the single-spec case so the OTP chip13- * keeps the only inline slot.8+ * Which inline spec hosts the "Search Peach…" chip.9+ *10+ * v2.14.6 collision fix (holds): with a single inline spec and an OTP chip11+ * eligible, both chips would target specs[0] and the picker dataset -- added12+ * last -- would clobber the 2FA chip. The picker drops to the system menu so13+ * the OTP chip keeps the lone slot.14+ *15+ * v2.14.8 refinement: without an OTP chip the stale two-slot reservation16+ * left the last spec empty (and with 2 specs the entry budget at zero). The17+ * picker now anchors the last slot itself when no 2FA chip is eligible.1418 */1519 class AutofillPickerSpecSelectionTest {1620 private val a = "spec-A"1721 private val b = "spec-B"1822 private val c = "spec-C"23+ private val d = "spec-D"1924 25+ // --- no 2FA chip: picker anchors the last slot (v2.14.8) ---26+2027 @Test fun empty_specs_yield_null_picker() {21- assertNull(pickerSpecFor(emptyList()))28+ assertNull(pickerSpecFor(emptyList(), otpEligible = false))29+ assertNull(pickerSpecFor(emptyList(), otpEligible = true))2230 }2331 24- @Test fun single_spec_yields_null_picker_so_otp_keeps_the_slot() {25- assertNull(pickerSpecFor(listOf(a)))32+ @Test fun single_spec_goes_to_the_picker_when_no_otp_chip() {33+ assertEquals(a, pickerSpecFor(listOf(a), otpEligible = false))2634 }2735 28- @Test fun two_specs_put_picker_in_the_first_slot() {29- assertEquals(a, pickerSpecFor(listOf(a, b)))36+ @Test fun without_otp_the_picker_takes_the_last_slot() {37+ assertEquals(b, pickerSpecFor(listOf(a, b), otpEligible = false))38+ assertEquals(c, pickerSpecFor(listOf(a, b, c), otpEligible = false))39+ assertEquals(d, pickerSpecFor(listOf(a, b, c, d), otpEligible = false))3040 }3141 32- @Test fun three_specs_put_picker_in_the_middle_slot() {33- assertEquals(b, pickerSpecFor(listOf(a, b, c)))42+ // --- with a 2FA chip: picker sits just before the OTP slot (v2.14.6) ---43+44+ @Test fun single_spec_yields_null_picker_so_otp_keeps_the_slot() {45+ assertNull(pickerSpecFor(listOf(a), otpEligible = true))3446 }3547 36- @Test fun four_specs_put_picker_in_the_third_slot() {37- assertEquals(c, pickerSpecFor(listOf(a, b, c, "spec-D")))48+ @Test fun with_otp_the_picker_sits_before_the_2fa_slot() {49+ assertEquals(a, pickerSpecFor(listOf(a, b), otpEligible = true))50+ assertEquals(b, pickerSpecFor(listOf(a, b, c), otpEligible = true))51+ assertEquals(c, pickerSpecFor(listOf(a, b, c, d), otpEligible = true))3852 }3953 }