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
7 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.31+2.14.4

view file

app/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.

view file

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,

view file

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 }

view file

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 }

view file

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+}

view file

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.

view file