aae41f730729
feat: OTP FILL chip subtitle='2FA' + passkey default-on for new installs (v2.14.4)
- time
- 2026-08-05 13:55 UTC · 2 months ago
- machine
- koh2
- parent
- 4e65f38511ab
↓ peach-android-aae41f73.tar.gzatlas at this save
files changed7 files
VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+74 −10app/src/main/java/com/peachpasswords/vault/VaultSettings.kt+5 −1app/src/test/java/com/peachpasswords/autofill/AutofillPreviewLabelsTest.kt+65 −0app/src/test/java/com/peachpasswords/autofill/AutofillTwoFactorChipLabelTest.kt+35 −39app/src/test/java/com/peachpasswords/vault/VaultSettingsDefaultsTest.kt+38 −0releases/v2.14.4.md+4 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+74 −10
@@ -81,23 +81,70 @@8181 * [iconSource] seeds the chip monogram; null keeps the Peach mark8282 * (always the case in privacy mode — initials would leak titles too).8383 */84- data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)84+ enum class FillMode { LOGIN, OTP_FILL }8585 86+ data class PreviewLabels(87+ val title: String,88+ val subtitle: String,89+ val iconSource: String?,90+ val fillMode: FillMode = FillMode.LOGIN,91+ )92+8693 /**8794 * Privacy previews: with [privatePreview] the rows and chips render only8895 * the Peach brand and the caller's domain — no entry titles or usernames8996 * in the IME's suggestion strip. The values still fill on tap.9097 */91- fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =92- if (privatePreview) {93- PreviewLabels("Peach", originDomain ?: "", null)94- } else {98+ /**99+ * Chunk a TOTP code for chip readability: "482901" -> "482 901". Alphanumeric100+ * codes (Steam-style base32: "JBSWY3DPEHPK3PXP") and codes longer than 6 digits101+ * pass through unchanged — a 7-digit code is rarely grouped, and stripping102+ * letters to chunk the digits would mutilate the code. The autofilled value103+ * stays the raw code so the form receives what its regex expects.104+ */105+ internal fun chunkOtpForDisplay(code: String): String {106+ if (code.length != 6 || !code.all { it.isDigit() }) return code107+ return code.chunked(3).joinToString(" ")108+ }109+110+ /**111+ * Build the chip labels for an entry's fill. Pass [FillMode.OTP_FILL] when112+ * the dataset fills ONLY an OTP field (no identity/password) so the chip113+ * surfaces the actual TOTP code as the title and the literal "2FA" in the114+ * subtitle. The entry's favicon/initial on the left already conveys the115+ * entry, so a duplicate-name subtitle invites the user to misread the116+ * digits as their password. Private preview always wins: digits are never117+ * leaked into the chip strip even in this mode.118+ */119+ fun previewLabels(120+ privatePreview: Boolean,121+ entry: VaultEntry,122+ originDomain: String?,123+ fillMode: FillMode = FillMode.LOGIN,124+ otpCode: String? = null,125+ ): PreviewLabels = when {126+ privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)127+ fillMode == FillMode.OTP_FILL -> {128+ val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }129+ // Subtitle reads "2FA" instead of the entry title so the user can130+ // tell a TOTP chip from a password chip at a glance — the entry's131+ // favicon/initial on the left already conveys the entry, so a132+ // duplicate-name subtitle invites the user to misread the digits133+ // as their password.95134 PreviewLabels(96- entry.title,97- entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",98- entry.title,135+ title = chunkOtpForDisplay(code),136+ subtitle = "2FA",137+ iconSource = entry.title,138+ fillMode = FillMode.OTP_FILL,99139 )100140 }141+ else -> PreviewLabels(142+ entry.title,143+ entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",144+ entry.title,145+ FillMode.LOGIN,146+ )147+ }101148 102149 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =103150 RemoteViews(context.packageName, R.layout.autofill_row).apply {@@ -122,7 +169,23 @@122169 inlineSpec: android.widget.inline.InlinePresentationSpec?,123170 privatePreview: Boolean,124171 ): Dataset? {125- val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))172+ // Generate the TOTP code once and route it to BOTH the chip title (when173+ // the dataset fills ONLY an OTP field) and the fill value (always).174+ // The FillMode.OTP_FILL chip surfaces the actual digits in the175+ // keyboard strip so the user can confirm what they're about to type.176+ val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }177+ val fillingOnlyOtp = parsed.identityId == null &&178+ parsed.passwordId == null &&179+ parsed.otpId != null &&180+ otpCode != null181+ val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN182+ val labels = previewLabels(183+ privatePreview,184+ entry,185+ parsed.origin?.let(::extractDomain),186+ fillMode = fillMode,187+ otpCode = if (fillingOnlyOtp) otpCode else null,188+ )126189 val menu = rowFor(context, labels)127190 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {128191 buildInline(context, labels, inlineSpec)@@ -144,7 +207,8 @@144207 }145208 put(parsed.identityId, entry.username ?: entry.email)146209 put(parsed.passwordId, entry.password)147- val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }210+ // otpCode was computed once at the top of datasetFor so the chip can211+ // display it; just route it into the fill here.148212 put(parsed.otpId, otpCode)149213 // A6: split single-character OTP boxes — one character per box150214 // (browser's fillOTPDigitBoxes). Only when no full-code field exists.app/src/main/java/com/peachpasswords/vault/VaultSettings.kt+5 −1
@@ -17,7 +17,11 @@1717 val sortOrder: String = "url-first",1818 /** Keyboard chips and suggestion rows show only "Peach" until tapped. */1919 val hideAutofillPreviews: Boolean = false,20- val passkeyProviderEnabled: Boolean = false,20+ // Default-on for new installs (Peach handles passkey sign-in / creation through21+ // the Android Credential Manager). Existing users are not migrated: the stored22+ // setting is preserved on load, so anyone who explicitly disabled it stays23+ // off until they re-enable it in Settings → Passkeys.24+ val passkeyProviderEnabled: Boolean = true,2125 val duressConfigured: Boolean = false,2226 val shortcuts: Map<String, ShortcutBinding> = emptyMap(),2327 val allowScreenshots: Boolean = false,app/src/test/java/com/peachpasswords/autofill/AutofillPreviewLabelsTest.kt+65 −0
@@ -1,5 +1,6 @@11 package com.peachpasswords.autofill22 3+import com.peachpasswords.autofill.AutofillResponseBuilder.FillMode34 import com.peachpasswords.vault.VaultEntry45 import org.junit.Assert.assertEquals56 import org.junit.Test@@ -19,6 +20,7 @@1920 val labels = AutofillResponseBuilder.previewLabels(false, entry(), "github.com")2021 assertEquals("GitHub", labels.title)2122 assertEquals("june", labels.subtitle)23+ assertEquals(FillMode.LOGIN, labels.fillMode)2224 }2325 2426 @Test fun normal_mode_falls_back_email_then_domain() {@@ -38,5 +40,68 @@3840 val labels = AutofillResponseBuilder.previewLabels(true, entry(), null)3941 assertEquals("Peach", labels.title)4042 assertEquals("", labels.subtitle)43+ }44+45+ // --- OTP_FILL fillMode: the chip surfaces the actual TOTP code ---46+47+ @Test fun otpFill_mode_shows_chunked_code_as_title_and_entry_as_subtitle() {48+ val labels = AutofillResponseBuilder.previewLabels(49+ privatePreview = false,50+ entry = entry(),51+ originDomain = "github.com",52+ fillMode = FillMode.OTP_FILL,53+ otpCode = "482901",54+ )55+ assertEquals("482 901", labels.title)56+ assertEquals("2FA", labels.subtitle)57+ assertEquals("GitHub", labels.iconSource)58+ assertEquals(FillMode.OTP_FILL, labels.fillMode)59+ }60+61+ @Test fun otpFill_mode_passes_through_long_codes_unchunked() {62+ val labels = AutofillResponseBuilder.previewLabels(63+ privatePreview = false,64+ entry = entry(),65+ originDomain = null,66+ fillMode = FillMode.OTP_FILL,67+ otpCode = "JBSWY3DPEHPK3PXP",68+ )69+ // 8-char Steam-style code — chunk only fires on <=6 digits, this passes through.70+ assertEquals("JBSWY3DPEHPK3PXP", labels.title)71+ assertEquals("2FA", labels.subtitle)72+ }73+74+ @Test fun otpFill_mode_never_leaks_digits_in_private_preview() {75+ val labels = AutofillResponseBuilder.previewLabels(76+ privatePreview = true,77+ entry = entry(),78+ originDomain = "github.com",79+ fillMode = FillMode.OTP_FILL,80+ otpCode = "482901",81+ )82+ // Privacy always wins over fillMode — chip says "Peach" not the digits.83+ assertEquals("Peach", labels.title)84+ assertEquals("github.com", labels.subtitle)85+ }86+87+ @Test(expected = IllegalArgumentException::class)88+ fun otpFill_mode_requires_otpCode() {89+ AutofillResponseBuilder.previewLabels(90+ privatePreview = false,91+ entry = entry(),92+ originDomain = "github.com",93+ fillMode = FillMode.OTP_FILL,94+ otpCode = null,95+ )96+ }97+98+ @Test fun chunkOtpForDisplay_groups_only_6digit_codes_with_space() {99+ // 6-digit only: grouped into 3+3.100+ assertEquals("482 901", AutofillResponseBuilder.chunkOtpForDisplay("482901"))101+ // 5-digit and 7-digit: pass through (no sane chunking).102+ assertEquals("12345", AutofillResponseBuilder.chunkOtpForDisplay("12345"))103+ assertEquals("1234567", AutofillResponseBuilder.chunkOtpForDisplay("1234567"))104+ // Alphanumeric (Steam-style base32): pass through unchanged — never strip letters.105+ assertEquals("JBSWY3DPEHPK3PXP", AutofillResponseBuilder.chunkOtpForDisplay("JBSWY3DPEHPK3PXP"))41106 }42107 }app/src/test/java/com/peachpasswords/autofill/AutofillTwoFactorChipLabelTest.kt+35 −39
@@ -1,15 +1,18 @@11 package com.peachpasswords.autofill22 3+import com.peachpasswords.autofill.AutofillResponseBuilder.FillMode34 import com.peachpasswords.vault.VaultEntry45 import org.junit.Assert.assertEquals6+import org.junit.Assert.assertNotEquals57 import org.junit.Test68 79 /**8- * Reads the chip title/subtitle that [AutofillResponseBuilder.datasetFor]9- * will render on a 2FA-only page, and confirms they are the SAME as on a10- * login page. If this test ever fails differently across the two cases, the11- * 2FA fill became visually distinguishable. Tied to user-facing intent:12- * users must be able to tell whether a chip fills creds or fills a code.10+ * Confirms the keyboard-row chip carries WHAT it will fill. Users must be11+ * able to tell a creds-fill chip from a TOTP-fill chip at a glance — same12+ * labels on both pages was a real user-visible gap (logged 2026-08-05).13+ *14+ * The fix routes the OTP-only fill path through a separate FillMode so the15+ * chip title carries the actual TOTP code instead of the entry title.1316 */1417 class AutofillTwoFactorChipLabelTest {1518 @@ -24,53 +27,46 @@2427 createdAt = 0, modifiedAt = 0,2528 )2629 27- // These mirror the two real-world cases datasetFor computes against.28- // Until previewLabels knows about the field-set, both return identical29- // strings — which is exactly the user-visible gap that this test pins.30- private fun labelsForLogin(entry: VaultEntry) =30+ private fun loginLabels() =3131 AutofillResponseBuilder.previewLabels(3232 privatePreview = false,33- entry = entry,33+ entry = entry(),3434 originDomain = "github.com",35+ fillMode = FillMode.LOGIN,3536 )3637 37- private fun labelsFor2FA(entry: VaultEntry) =38+ private fun otpLabels(code: String = "482901") =3839 AutofillResponseBuilder.previewLabels(3940 privatePreview = false,40- entry = entry,41+ entry = entry(),4142 originDomain = "github.com",43+ fillMode = FillMode.OTP_FILL,44+ otpCode = code,4245 )4346 44- @Test fun login_and_2fa_chips_render_identically_today() {45- val e = entry()47+ @Test fun login_and_2fa_chips_now_differ() {48+ val login = loginLabels()49+ val otp = otpLabels()4650 println("--- LOGIN chip labels ---")47- println(" title=${labelsForLogin(e).title}")48- println(" subtitle=${labelsForLogin(e).subtitle}")51+ println(" title=${login.title} subtitle=${login.subtitle}")4952 println("--- 2FA chip labels ---")50- println(" title=${labelsFor2FA(e).title}")51- println(" subtitle=${labelsFor2FA(e).subtitle}")52- // Same code path returns same labels. The user has no visual cue53- // distinguishing what each chip fills. (This is the bug; the fix54- // will diverge them — see previewLabelsWhenOnlyOtpFilled.)55- assertEquals(labelsForLogin(e).title, labelsFor2FA(e).title)56- assertEquals(labelsForLogin(e).subtitle, labelsFor2FA(e).subtitle)53+ println(" title=${otp.title} subtitle=${otp.subtitle}")54+ assertEquals("GitHub", login.title)55+ assertEquals("june", login.subtitle)56+ assertEquals("482 901", otp.title)57+ assertEquals("2FA", otp.subtitle)58+ assertNotEquals(login.title, otp.title)59+ assertNotEquals(login.subtitle, otp.subtitle)5760 }5861 59- /**60- * Once the fix lands, [AutofillResponseBuilder.previewLabels] — or a61- * caller passing a fill-mode hint — should produce a different subtitle62- * when the dataset only fills OTP. This test pins the expected63- * post-fix behavior so a regression doesn't quietly re-collapse them.64- */65- @Test fun previewLabelsWhenOnlyOtpFilled_pins_expected_post_fix_string() {66- // After the fix, when datasetFor is filling only OTP the chip67- // subtitle should clearly say "2FA code" instead of "june". This68- // test asserts the substring expectation so the chip copy is locked.69- val e = entry()70- // The current labels API doesn't accept a fill-mode hint, so this71- // assertion documents the contract: re-implement the test once the72- // labels API gains the field-set awareness.73- val today = labelsFor2FA(e).subtitle74- assertEquals("june", today) // current snapshot — replace once fix lands62+ @Test fun otp_label_tracks_the_code_passed_in() {63+ // The chip is recomputed each call — a fresh code passed in shows up64+ // as the title. Verification gate for the datasetFor recompute path65+ // (Phase B), which will route a chip-tap through this same labels66+ // function with a newly-generated TOTP code.67+ val one = otpLabels("111111")68+ val two = otpLabels("222222")69+ assertEquals("111 111", one.title)70+ assertEquals("222 222", two.title)7571 }7672 }app/src/test/java/com/peachpasswords/vault/VaultSettingsDefaultsTest.kt+38 −0
@@ -0,0 +1,38 @@1+package com.peachpasswords.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * Gate for the new-install default. The Credential Manager provider is on by10+ * default for fresh installs so users get passkey sign-in without having to11+ * dig into Settings. Existing users who explicitly disabled it must stay12+ * off — the stored value is preserved over the default (see [VaultStore]13+ * which falls back to [VaultSettings] only when nothing is on disk).14+ */15+class VaultSettingsDefaultsTest {16+17+ @Test fun new_install_default_has_passkey_enabled() {18+ assertTrue(19+ "first-launch default must enable passkey provider — user said 'q2 is correct'",20+ VaultSettings().passkeyProviderEnabled,21+ )22+ }23+24+ @Test fun explicitly_disabled_setting_round_trips_through_codec() {25+ val original = VaultSettings(passkeyProviderEnabled = false)26+ val encoded = VaultCodec.encodeSettings(original)27+ val decoded = VaultCodec.decodeSettings(encoded)28+ assertEquals(original, decoded)29+ assertFalse("explicit off must not be auto-flipped to on by load", decoded!!.passkeyProviderEnabled)30+ }31+32+ @Test fun explicitly_enabled_setting_round_trips_through_codec() {33+ val original = VaultSettings(passkeyProviderEnabled = true)34+ val encoded = VaultCodec.encodeSettings(original)35+ val decoded = VaultCodec.decodeSettings(encoded)36+ assertEquals(original, decoded)37+ }38+}releases/v2.14.4.md+4 −0
@@ -0,0 +1,4 @@1+## Highlights2+- Inline TOTP chip now reads `2FA` instead of the entry name, so the digits can't be mistaken for a password. The chip title still shows the actual 6-digit code (e.g. `482 901`) — the favicon/initial on the left identifies the entry.3+- Passkey / Credential Manager is on by default for new installs. Existing users keep their stored setting.4+- Multi-part login flow keeps the smart TOTP timeout surfacing: refocusing the 2FA field after the 30-second code rotation gives the fresh chip automatically.