0f4da0336e83

feat: 2FA countdown chip + OTP tap-to-fill (v2.14.5)

time
2026-08-05 15:25 UTC · 2 months ago
machine
koh2
parent
7bbba9fcd64b
14 files VERSION+1 −1app/src/androidTest/AndroidManifest.xml+1 −1app/src/androidTest/java/com/peachpasswords/qa/QaFormActivity.java+10 −0app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+51 −0app/src/androidTest/java/com/peachpasswords/qa/VaultSeedTest.kt+5 −1app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+18 −2app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+120 −6app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+26 −1app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+10 −1app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+18 −4app/src/test/java/com/peachpasswords/autofill/AssistStructureOtpRegexTest.kt+48 −0app/src/test/java/com/peachpasswords/autofill/AutofillPreviewLabelsTest.kt+69 −0app/src/test/java/com/peachpasswords/autofill/OtpDatasetTest.kt+86 −0releases/v2.14.5.md+8 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.41+2.14.5

view file

app/src/androidTest/AndroidManifest.xml+1 −1
@@ -1,6 +1,6 @@11 <?xml version="1.0" encoding="utf-8"?>22 <manifest xmlns:android="http://schemas.android.com/apk/res/android">3-    <application>3+    <application android:label="GitHub">44         <activity55             android:name="com.peachpasswords.qa.QaFormActivity"66             android:exported="true" />

view file

app/src/androidTest/java/com/peachpasswords/qa/QaFormActivity.java+10 −0
@@ -30,11 +30,21 @@3030         password.setAutofillHints("password");3131         password.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);3232 33+        // 2FA field for the OTP chip QA path (E2-fix). The hint goes via34+        // the framework -- the parser also walks idEntry / hint / text as a35+        // fallback when the hint is missing on real-world forms.36+        EditText otp = new EditText(this);37+        otp.setHint("2FA Code");38+        otp.setInputType(InputType.TYPE_CLASS_NUMBER);39+        otp.setAutofillHints("otp");40+        otp.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);41+3342         LinearLayout layout = new LinearLayout(this);3443         layout.setOrientation(LinearLayout.VERTICAL);3544         layout.setPadding(48, 96, 48, 0);3645         layout.addView(username);3746         layout.addView(password);47+        layout.addView(otp);3848         setContentView(layout);3949     }4050 }

view file

app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+51 −0
@@ -0,0 +1,51 @@1+package com.peachpasswords.qa2+3+import androidx.test.ext.junit.runners.AndroidJUnit44+import androidx.test.platform.app.InstrumentationRegistry5+import java.io.BufferedReader6+import java.io.InputStreamReader7+import org.junit.Assert.assertTrue8+import org.junit.Test9+import org.junit.runner.RunWith10+11+/**12+ * Emulator QA: does the autofill framework issue a fill request for a form13+ * that includes a 2FA field, and does the OTP chip appear in the inline14+ * suggestion strip? The chip carries the actual TOTP code -- the regression15+ * guard for "2FA fill is not working from the chip" (continuity 2026-08-05).16+ *17+ * Uses [QaFormActivity] which now has username + password + 2FA fields.18+ * Tapping the username field triggers a FillRequest; the IME renders the19+ * entry chip and the OTP chip (when a TOTP-enabled entry matches).20+ */21+@RunWith(AndroidJUnit4::class)22+class QaFormTwoFactorChipTest {23+24+    @Test fun framework_issues_fill_request_for_form_with_2fa_field() {25+        shell("am force-stop com.peachpasswords.debug.test")26+        shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaFormActivity")27+        Thread.sleep(2000)28+        // Username field: top of screen after the 96px top padding.29+        shell("input tap 360 135")30+        Thread.sleep(4000)31+        val dump = shell("dumpsys autofill")32+        val mentions = dump.lineSequence()33+            .filter { it.contains("com.peachpasswords.debug.test") }34+            .take(10)35+            .joinToString("\n")36+        assertTrue(37+            "no autofill session for the 2FA form -- framework never issued a fill request.\n" +38+                "dumpsys excerpt:\n$mentions",39+            mentions.isNotEmpty(),40+        )41+    }42+43+    private fun shell(command: String): String {44+        val instrumentation = InstrumentationRegistry.getInstrumentation()45+        instrumentation.uiAutomation.executeShellCommand(command).use { pfd ->46+            BufferedReader(InputStreamReader(android.os.ParcelFileDescriptor.AutoCloseInputStream(pfd))).use {47+                return it.readText()48+            }49+        }50+    }51+}

view file

app/src/androidTest/java/com/peachpasswords/qa/VaultSeedTest.kt+5 −1
@@ -46,7 +46,11 @@4646                 assertEquals(UnlockResult.Success, vault.unlock(masterPassword))4747             }4848             if (vault.getEntries().isEmpty()) {49-                vault.addEntry(login("GitHub", "https://github.com"))49+                // GitHub gets TOTP for the OTP chip QA path (E2-fix). The seed50+                // is the well-known "Hello!Þ­¾ï" base32 string51+                // used in every TOTP unit test -- produces a deterministic52+                // code that matches what the chip will render.53+                vault.addEntry(login("GitHub", "https://github.com").copy(totp = "JBSWY3DPEHPK3PXP"))5054                 vault.addEntry(login("Proton Mail", "https://account.proton.me/login"))5155                 vault.addEntry(login("Vercel", "https://vercel.com/login"))5256             }

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+18 −2
@@ -177,7 +177,8 @@177177                 View.AUTOFILL_HINT_PASSWORD, "password" -> return FieldKind.PASSWORD178178                 View.AUTOFILL_HINT_USERNAME, "username" -> return FieldKind.USERNAME179179                 View.AUTOFILL_HINT_EMAIL_ADDRESS, "email", "emailaddress" -> return FieldKind.EMAIL180-                "otp", "smsotpcode", "2facode" -> return FieldKind.OTP180+                "otp", "smsotpcode", "2facode",181+                "one-time-password", "one_time_password", "totp" -> return FieldKind.OTP181182                 View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, "creditcardnumber", "ccnumber" -> return FieldKind.CARD_NUMBER182183                 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, "creditcardexpirationmonth" -> return FieldKind.CARD_EXP_MONTH183184                 View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, "creditcardexpirationyear" -> return FieldKind.CARD_EXP_YEAR@@ -196,7 +197,13 @@196197             .joinToString(" ").lowercase()197198         return when {198199             haystack.isBlank() -> FieldKind.NONE199-            Regex("\\b(otp|2fa|mfa|one[- ]?time|verification code|auth code)\\b").containsMatchIn(haystack) -> FieldKind.OTP200+            // OTP/2FA haystack: cover the labels real 2FA forms actually use,201+            // not just the textbook ones. The framework only sets "otp" hint202+            // when the developer opts in to autofill; most don't, so the field203+            // ends up with a generic label and a missed detection. Standalone204+            // "code" is intentionally absent -- promo / coupon / discount codes205+            // match it too and would mis-fill with a TOTP.206+            OTP_HAYSTACK_RX.containsMatchIn(haystack) -> FieldKind.OTP200207             haystack.contains("password") || haystack.contains("passwd") || haystack.contains("pwd") -> FieldKind.PASSWORD201208             haystack.contains("email") || haystack.contains("e-mail") -> FieldKind.EMAIL202209             haystack.contains("user") || haystack.contains("login") || haystack.contains("account") -> FieldKind.USERNAME@@ -209,6 +216,15 @@209216             else -> FieldKind.NONE210217         }211218     }219+220+    /**221+     * Heuristic for OTP / 2FA fields. Covers the labels real 2FA forms222+     * actually use -- not just the textbook ones. Standalone "code" is223+     * intentionally absent (promo / coupon / discount codes would mis-match).224+     */225+    internal val OTP_HAYSTACK_RX = Regex(226+        "\\b(otp|2fa|mfa|one[- ]?time|verification|auth(entication)?|security code|token|confirm)\\b",227+    )212228 213229     private val CARD_NUMBER_RX = Regex("\\b(card\\s*number|credit\\s*card|card\\s*no|pan|cc\\s*num)\\b")214230     private val CARD_EXP_MONTH_RX = Regex("\\b(exp\\s*month|expiration\\s*month|expiry\\s*month)\\b")

view file

app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+120 −6
@@ -10,6 +10,11 @@1010 import android.view.autofill.AutofillValue1111 import android.widget.RemoteViews1212 import androidx.annotation.RequiresApi13+import android.graphics.Bitmap14+import android.graphics.Canvas15+import android.graphics.Paint16+import android.graphics.drawable.Icon17+import android.util.LruCache1318 import androidx.autofill.inline.v1.InlineSuggestionUi1419 import androidx.autofill.inline.UiVersions1520 import com.peachpasswords.R@@ -88,6 +93,7 @@8893         val subtitle: String,8994         val iconSource: String?,9095         val fillMode: FillMode = FillMode.LOGIN,96+        val endIcon: Icon? = null,9197     )9298 9399     /**@@ -122,6 +128,7 @@122128         originDomain: String?,123129         fillMode: FillMode = FillMode.LOGIN,124130         otpCode: String? = null,131+        secondsRemaining: Int? = null,125132     ): PreviewLabels = when {126133         privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)127134         fillMode == FillMode.OTP_FILL -> {@@ -130,12 +137,23 @@130137             // tell a TOTP chip from a password chip at a glance — the entry's131138             // favicon/initial on the left already conveys the entry, so a132139             // duplicate-name subtitle invites the user to misread the digits133-            // as their password.140+            // as their password. When [secondsRemaining] is known we141+            // append the TOTP window countdown (the chip itself is static;142+            // a fresh chip with a fresh code is rebuilt on every focus-change143+            // FillRequest, so the user only needs to tap off and back into144+            // the field to get the next code).145+            val subtitle = when {146+                secondsRemaining == null -> "2FA"147+                secondsRemaining <= 0 -> "2FA · expired"148+                else -> "2FA · ${secondsRemaining}s"149+            }150+            val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }134151             PreviewLabels(135152                 title = chunkOtpForDisplay(code),136-                subtitle = "2FA",153+                subtitle = subtitle,137154                 iconSource = entry.title,138155                 fillMode = FillMode.OTP_FILL,156+                endIcon = endIcon,139157             )140158         }141159         else -> PreviewLabels(@@ -173,7 +191,8 @@173191         // the dataset fills ONLY an OTP field) and the fill value (always).174192         // The FillMode.OTP_FILL chip surfaces the actual digits in the175193         // keyboard strip so the user can confirm what they're about to type.176-        val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }194+        val otp = entry.totp?.let { TOTPGenerator.generate(it) }195+        val otpCode = otp?.code177196         val fillingOnlyOtp = parsed.identityId == null &&178197             parsed.passwordId == null &&179198             parsed.otpId != null &&@@ -185,6 +204,7 @@185204             parsed.origin?.let(::extractDomain),186205             fillMode = fillMode,187206             otpCode = if (fillingOnlyOtp) otpCode else null,207+            secondsRemaining = otp?.remaining,188208         )189209         val menu = rowFor(context, labels)190210         val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {@@ -207,9 +227,14 @@207227         }208228         put(parsed.identityId, entry.username ?: entry.email)209229         put(parsed.passwordId, entry.password)210-        // otpCode was computed once at the top of datasetFor so the chip can211-        // display it; just route it into the fill here.212-        put(parsed.otpId, otpCode)230+        // OTP fill on a multi-field form is handled by [otpDatasetFor] -- a231+        // separate dataset with its own chip on the 2FA field. The LOGIN chip232+        // intentionally does NOT fill otp here, so the user can verify the233+        // code before it lands: "tap github fills email and password, then234+        // automatically surfaces the next chip with 2FA code" (continuity235+        // 2026-08-05). On an OTP-only form (fillingOnlyOtp) we keep filling236+        // from the LOGIN path -- there's no separate chip to defer to.237+        if (fillingOnlyOtp) put(parsed.otpId, otpCode)213238         // A6: split single-character OTP boxes — one character per box214239         // (browser's fillOTPDigitBoxes). Only when no full-code field exists.215240         if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {@@ -223,6 +248,51 @@223248     }224249 225250     /**251+     * A second inline dataset for the 2FA chip: fills ONLY the detected OTP252+     * field with a freshly generated TOTP code and renders its chip in253+     * [FillMode.OTP_FILL] (title = chunked code, subtitle = "2FA"). The user254+     * wanted the LOGIN chip and the 2FA chip as separate affordances:255+     * "tap 'github' it fills email and password, then automatically surfaces256+     * the next chip with 2FA code" (continuity 2026-08-05). The chip lands on257+     * the spec whose autofillId matches [parsed.otpId]; if no spec matches258+     * (the framework only offered a single spec on a focused 2FA field, say),259+     * the dataset still surfaces in the system menu.260+     */261+    fun otpDatasetFor(262+        context: Context,263+        parsed: ParsedStructure,264+        entry: VaultEntry,265+        inlineSpec: android.widget.inline.InlinePresentationSpec?,266+        privatePreview: Boolean,267+    ): Dataset? {268+        val otpId = parsed.otpId ?: return null269+        val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null270+        val otpCode = otp.code271+        val labels = previewLabels(272+            privatePreview = privatePreview,273+            entry = entry,274+            originDomain = parsed.origin?.let(::extractDomain),275+            fillMode = FillMode.OTP_FILL,276+            otpCode = otpCode,277+            secondsRemaining = otp.remaining,278+        )279+        val menu = rowFor(context, labels)280+        val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {281+            buildInline(context, labels, inlineSpec)282+        } else {283+            null284+        }285+        val builder = Dataset.Builder()286+        val av = AutofillValue.forText(otpCode)287+        if (inline != null && Build.VERSION.SDK_INT >= 30) {288+            builder.setValue(otpId, av, menu, inline)289+        } else {290+            builder.setValue(otpId, av, menu)291+        }292+        return builder.build()293+    }294+295+    /**226296      * Build a dataset filling detected card fields from a card [entry].227297      * Cards are not domain-matched — any detected card form offers all cards.228298      */@@ -417,6 +487,7 @@417487                 .setTitle(labels.title)418488                 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }419489                 .setStartIcon(startIcon)490+                .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }420491                 .build()421492                 .slice422493             InlinePresentation(slice, spec, false)@@ -424,5 +495,48 @@424495     } catch (_: Throwable) {425496         // A non-conforming IME spec must never crash the fill — degrade to menu.426497         null498+    }499+500+    /**501+     * Small colored dot rendered as the inline chip's end icon when the 2FA502+     * chip is in [FillMode.OTP_FILL] with a known [secondsRemaining].503+     *504+     * Colors match the urgency: green (>= 10s), amber (5..9s), red (<= 4s505+     * or already expired). The chip itself is a static RemoteViews render506+     * — once built, no live animation runs; a fresh chip with a fresh507+     * code is returned on the next focus-change FillRequest, so the508+     * user only needs to tap off the keyboard and back into the 2FA509+     * field to get the next code. Cached by color so per-fill alloc is510+     * constant.511+     */512+    private object OtpCountdownDot {513+        private const val SIZE_PX = 32514+        private const val GREEN = 0xFF6FB07F.toInt()515+        private const val AMBER = 0xFFE0A857.toInt()516+        private const val RED = 0xFFD8534B.toInt()517+        private val cache = LruCache<Int, Icon>(8)518+519+        fun icon(secondsRemaining: Int): Icon? {520+            val color = when {521+                secondsRemaining <= 4 -> RED522+                secondsRemaining <= 9 -> AMBER523+                else -> GREEN524+            }525+            cache.get(color)?.let { return it }526+            // Bitmap creation is a no-op on the JVM test runtime (the527+            // android.jar stubs return null for createBitmap). Fail-soft528+            // there — the chip still gets the seconds-left text; the529+            // colored dot only matters on a real device where the dot530+            // is also the glanceable expiry signal the user asked for.531+            val icon = runCatching {532+                val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)533+                val canvas = Canvas(bm)534+                val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }535+                canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)536+                Icon.createWithBitmap(bm)537+            }.getOrNull() ?: return null538+            cache.put(color, icon)539+            return icon540+        }427541     }428542 }

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+26 −1
@@ -65,6 +65,18 @@6565             inlineSpecs.lastOrNull()6666         }6767         val createSpec = inlineSpecs.lastOrNull()68+        // When the form has a detected 2FA field AND a matching TOTP-enabled69+        // entry exists, the OTP chip takes the last inline spec slot (which70+        // is conventionally the 2FA field on a username/password/2FA form --71+        // the framework sends specs in field-order). The create chip drops to72+        // the system drop-down only; the user can still reach it via the73+        // menu. InlinePresentationSpec doesn't carry an autofillId, so we74+        // can't pin the chip to the exact field -- the last-slot heuristic75+        // is the closest mapping the framework offers.76+        val otpEligible = parsed.otpId != null &&77+            vault.getEntries().any { !it.totp.isNullOrBlank() }78+        val otpSpec = if (otpEligible) createSpec else null79+        val otpTakesCreateSlot = otpEligible && createSpec != null6880         var entryChips = 06981 7082         if (unlocked) {@@ -80,6 +92,16 @@8092                     datasetCount++8193                     hasLoginMatch = true8294                 }95+                // Dedicated 2FA chip on the last inline spec slot -- same96+                // dataset family, OTP_FILL labels (chunked code as title,97+                // "2FA" subtitle). Separate affordance from the LOGIN chip so98+                // the user can verify the code before it lands.99+                if (!entry.totp.isNullOrBlank() && otpSpec != null) {100+                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {101+                        response.addDataset(it)102+                        datasetCount++103+                    }104+                }83105             }84106         }85107 @@ -124,10 +146,13 @@124146             )125147             datasetCount++126148         }149+        // Create chip moves to the system drop-down only when the OTP chip150+        // took its inline spec slot -- the 2FA chip is the higher-value151+        // affordance and the create path is still one tap away via the menu.127152         AutofillResponseBuilder.createNewEntryDataset(128153             context = context,129154             parsed = parsed,130-            inlineSpec = createSpec,155+            inlineSpec = if (otpTakesCreateSlot) null else createSpec,131156             unlocked = unlocked,132157             origin = parsed.origin,133158         )?.let {

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+10 −1
@@ -93,6 +93,9 @@9393         val req = newEntryRequest ?: return@LaunchedEffect9494         if (!unlocked) return@LaunchedEffect9595         autofillNav.consumeNewEntryRequest()96+        // popUpTo Lock inclusive: don't leave the lock screen in the back97+        // stack behind the new-entry editor (the back-press would land the98+        // user back at the lock screen with the vault still open).9699         navController.navigate(97100             PeachNav.EntryEdit.of(98101                 id = null,@@ -100,7 +103,10 @@100103                 prefillUsername = req.username,101104                 prefillEmail = req.email,102105             ),103-        ) { launchSingleTop = true }106+        ) {107+            launchSingleTop = true108+            popUpTo(PeachNav.Lock.route) { inclusive = true }109+        }104110     }105111     // Launcher shortcut / passkey-unlock action from the launch intent, held the106112     // same way: every one of these destinations needs an open vault, and the@@ -220,6 +226,9 @@220226                     }221227                 },222228                 onNavigate = nav,229+                // When an autofill nav request is pending, LockScreen must not230+                // also route to the last-viewed entry (see LaunchedEffect below).231+                autofillPending = newEntryRequest != null,223232             )224233         }225234         composable(PeachNav.VaultList.route) {

view file

app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+18 −4
@@ -74,6 +74,16 @@7474 fun LockScreen(7575     onUnlocked: (PeachNav) -> Unit,7676     onNavigate: (PeachNav) -> Unit,77+    /**78+     * True when an autofill-driven navigation (e.g. the "+ Save to Peach"79+     * create chip) is queued and waiting for the vault to unlock. When set,80+     * [onUnlocked] is skipped: the autofill nav path at the PeachAppNavHost81+     * scope will navigate to its own destination (the new-entry editor with82+     * URL prefilled), and we must not clobber it with the last-viewed entry.83+     * Two LaunchedEffects firing on the same unlock would otherwise race --84+     * the LockScreen one wins because [onUnlocked] uses popUpTo(0) inclusive.85+     */86+    autofillPending: Boolean = false,7787     vm: LockViewModel = hiltViewModel(),7888 ) {7989     val state by vm.state.collectAsState()@@ -88,15 +98,19 @@8898     val createScore = remember(password) { scorePassword(password) }8999     // Why the create button is disabled, shown on the button itself. Without90100     // it the button just silently does nothing (browser parity: Lock.tsx's91-    // createBlockerMessage). Null before typing — no scolding on an empty form.101+    // createBlockerMessage). Null before typing -- no scolding on an empty form.92102     val createBlocker = if (state.hasVault || password.isEmpty()) null93103                         else createValidationError(password, confirm, createScore)94104 95-    LaunchedEffect(state.unlocked) {105+    LaunchedEffect(state.unlocked, autofillPending) {96106         if (state.unlocked) {97107             // A freshly created vault routes through recovery-word setup before the list;98-            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).99-            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())108+            // an ordinary unlock lands on the last-viewed entry (brief Fix 3). The autofill109+            // nav host handles its own destinations when autofillPending is set; stepping in110+            // here would race with it (this LaunchedEffect's popUpTo(0) wins by ordering).111+            if (state.created) onNavigate(PeachNav.RecoverySetup)112+            else if (autofillPending) Unit113+            else onUnlocked(vm.postUnlockDestination())100114         }101115     }102116 

view file

app/src/test/java/com/peachpasswords/autofill/AssistStructureOtpRegexTest.kt+48 −0
@@ -0,0 +1,48 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+/**8+ * OTP / 2FA field detection. The autofill framework only sets the `otp`9+ * hint when the developer opts in; most don\'t, so the parser falls back to10+ * a regex over the field\'s id/hint/text. The regex has to catch the labels11+ * real 2FA forms actually use without mis-matching promo / coupon / discount12+ * code fields.13+ */14+class AssistStructureOtpRegexTest {15+16+    private val rx = AssistStructureParser.OTP_HAYSTACK_RX17+18+    @Test fun matches_textbook_2fa_labels() {19+        // The original textbook set the regex already covered.20+        assertTrue(rx.containsMatchIn("otp"))21+        assertTrue(rx.containsMatchIn("2fa"))22+        assertTrue(rx.containsMatchIn("mfa"))23+        assertTrue(rx.containsMatchIn("one-time password"))24+        assertTrue(rx.containsMatchIn("verification code"))25+        assertTrue(rx.containsMatchIn("auth code"))26+    }27+28+    @Test fun matches_labels_real_forms_use() {29+        // These were silent misses before the widening.30+        assertTrue(rx.containsMatchIn("verification"))31+        assertTrue(rx.containsMatchIn("authentication"))32+        assertTrue(rx.containsMatchIn("security code"))33+        assertTrue(rx.containsMatchIn("token"))34+        assertTrue(rx.containsMatchIn("confirm"))35+    }36+37+    @Test fun does_not_match_unrelated_code_fields() {38+        // A standalone "code" alone is intentionally absent: promo / coupon39+        // / discount fields all use it. Matching them would mis-fill with a40+        // TOTP on signup / checkout forms.41+        assertFalse(rx.containsMatchIn("code"))42+        assertFalse(rx.containsMatchIn("promo code"))43+        assertFalse(rx.containsMatchIn("coupon code"))44+        assertFalse(rx.containsMatchIn("discount code"))45+        assertFalse(rx.containsMatchIn("zip code"))46+        assertFalse(rx.containsMatchIn("area code"))47+    }48+}

view file

app/src/test/java/com/peachpasswords/autofill/AutofillPreviewLabelsTest.kt+69 −0
@@ -3,6 +3,7 @@33 import com.peachpasswords.autofill.AutofillResponseBuilder.FillMode44 import com.peachpasswords.vault.VaultEntry55 import org.junit.Assert.assertEquals6+import org.junit.Assert.assertNotNull67 import org.junit.Test78 89 class AutofillPreviewLabelsTest {@@ -103,5 +104,73 @@103104         assertEquals("1234567", AutofillResponseBuilder.chunkOtpForDisplay("1234567"))104105         // Alphanumeric (Steam-style base32): pass through unchanged — never strip letters.105106         assertEquals("JBSWY3DPEHPK3PXP", AutofillResponseBuilder.chunkOtpForDisplay("JBSWY3DPEHPK3PXP"))107+    }108+}109+110+// --- Countdown subtitle on the 2FA chip ---111+112+class AutofillPreviewLabelsCountdownTest {113+    private fun entry(title: String = "GitHub") = VaultEntry(114+        id = title, title = title, username = "june",115+        createdAt = 0, modifiedAt = 0,116+    )117+118+    @Test fun otpFill_chip_counts_down_seconds_remaining_in_subtitle() {119+        val labels = AutofillResponseBuilder.previewLabels(120+            privatePreview = false,121+            entry = entry(),122+            originDomain = "github.com",123+            fillMode = FillMode.OTP_FILL,124+            otpCode = "482901",125+            secondsRemaining = 23,126+        )127+        assertEquals("482 901", labels.title)128+        assertEquals("2FA · 23s", labels.subtitle)129+    }130+131+    @Test fun otpFill_chip_amber_zone_at_five_to_nine_seconds() {132+        val labels = AutofillResponseBuilder.previewLabels(133+            false, entry(), null,134+            fillMode = FillMode.OTP_FILL, otpCode = "123456", secondsRemaining = 7,135+        )136+        assertEquals("2FA · 7s", labels.subtitle)137+    }138+139+    @Test fun otpFill_chip_red_zone_at_or_below_four_seconds() {140+        val labels = AutofillResponseBuilder.previewLabels(141+            false, entry(), null,142+            fillMode = FillMode.OTP_FILL, otpCode = "123456", secondsRemaining = 3,143+        )144+        assertEquals("2FA · 3s", labels.subtitle)145+    }146+147+    @Test fun otpFill_chip_at_zero_or_below_shows_expired() {148+        val labels = AutofillResponseBuilder.previewLabels(149+            false, entry(), null,150+            fillMode = FillMode.OTP_FILL, otpCode = "123456", secondsRemaining = 0,151+        )152+        assertEquals("2FA · expired", labels.subtitle)153+    }154+155+    @Test fun otpFill_chip_without_secondsRemaining_keeps_legacy_subtitle() {156+        // Back-compat: callers that don't pass secondsRemaining get the same157+        // label as v2.14.4. No end-icon either — purely visual change gated158+        // on the freshness signal.159+        val labels = AutofillResponseBuilder.previewLabels(160+            false, entry(), "github.com",161+            fillMode = FillMode.OTP_FILL, otpCode = "482901",162+        )163+        assertEquals("2FA", labels.subtitle)164+        assertEquals(null, labels.endIcon)165+    }166+167+    @Test fun private_mode_still_hides_digits_even_with_countdown() {168+        // Privacy wins: "Peach" not the digits, no countdown leak either.169+        val labels = AutofillResponseBuilder.previewLabels(170+            true, entry(), "github.com",171+            fillMode = FillMode.OTP_FILL, otpCode = "482901", secondsRemaining = 7,172+        )173+        assertEquals("Peach", labels.title)174+        assertEquals("github.com", labels.subtitle)106175     }107176 }

view file

app/src/test/java/com/peachpasswords/autofill/OtpDatasetTest.kt+86 −0
@@ -0,0 +1,86 @@1+package com.peachpasswords.autofill2+3+import com.peachpasswords.autofill.AutofillResponseBuilder.FillMode4+import com.peachpasswords.vault.VaultEntry5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertFalse7+import org.junit.Assert.assertTrue8+import org.junit.Test9+10+/**11+ * E2-fix verification: the 2FA chip and the LOGIN chip are SEPARATE datasets12+ * so the user can verify the TOTP code before it lands. [datasetFor] no longer13+ * fills [parsed.otpId] when identity/password is present; [otpDatasetFor] is14+ * the dedicated 2FA path.15+ *16+ * Real-Dataset construction needs Android instrumentation (AutofillId is17+ * package-private). What we can verify in JVM tests is the label path: the18+ * LOGIN chip renders the entry title, the OTP chip renders the chunked code.19+ */20+class OtpDatasetTest {21+22+    private fun entry(23+        title: String = "GitHub",24+        username: String? = "june",25+        totp: String? = "JBSWY3DPEHPK3PXP",26+    ) = VaultEntry(27+        id = title, title = title, username = username, totp = totp,28+        createdAt = 0, modifiedAt = 0,29+    )30+31+    @Test fun otp_chip_shows_chunked_code_when_login_fields_also_present() {32+        // Multi-field form: identity + password + otp. The LOGIN chip carries33+        // the entry title; the OTP chip carries the chunked code -- two34+        // separate affordances, two separate labels.35+        val loginLabels = AutofillResponseBuilder.previewLabels(36+            privatePreview = false,37+            entry = entry(),38+            originDomain = "github.com",39+            fillMode = FillMode.LOGIN,40+        )41+        val otpLabels = AutofillResponseBuilder.previewLabels(42+            privatePreview = false,43+            entry = entry(),44+            originDomain = "github.com",45+            fillMode = FillMode.OTP_FILL,46+            otpCode = "482901",47+        )48+        assertEquals("GitHub", loginLabels.title)49+        assertEquals("482 901", otpLabels.title)50+        assertEquals("2FA", otpLabels.subtitle)51+        assertFalse(52+            "LOGIN and OTP chip titles must differ so the user can tell them apart",53+            loginLabels.title == otpLabels.title,54+        )55+        assertTrue(loginLabels.fillMode == FillMode.LOGIN)56+        assertTrue(otpLabels.fillMode == FillMode.OTP_FILL)57+    }58+59+    @Test fun otp_chip_regenerates_each_call() {60+        // The TOTP code rotates every 30s. The chip is rebuilt on every61+        // FillRequest, so a fresh code passed in surfaces as the title.62+        val first = AutofillResponseBuilder.previewLabels(63+            privatePreview = false, entry = entry(), originDomain = "github.com",64+            fillMode = FillMode.OTP_FILL, otpCode = "111111",65+        )66+        val second = AutofillResponseBuilder.previewLabels(67+            privatePreview = false, entry = entry(), originDomain = "github.com",68+            fillMode = FillMode.OTP_FILL, otpCode = "222222",69+        )70+        assertEquals("111 111", first.title)71+        assertEquals("222 222", second.title)72+    }73+74+    @Test fun otp_chip_in_private_mode_still_shows_peach_not_the_entry() {75+        // Hide-autofill-previews hides the title too. The 2FA chip stays76+        // generic ("Peach") with no entry title -- the digits would still77+        // leak if we kept them. (Digits ARE shown via the menu RemoteViews78+        // not the inline chip when privatePreview is on.)79+        val labels = AutofillResponseBuilder.previewLabels(80+            privatePreview = true, entry = entry(), originDomain = "github.com",81+            fillMode = FillMode.OTP_FILL, otpCode = "482901",82+        )83+        assertEquals("Peach", labels.title)84+        assertEquals("github.com", labels.subtitle)85+    }86+}

view file

releases/v2.14.5.md+8 −0
@@ -0,0 +1,8 @@1+## Highlights2+- The 2FA chip now shows the seconds left in its TOTP window directly on the chip ("2FA · 23s"). When the current code rolls past 0 the chip reads "2FA · expired"; refocusing the 2FA field rebuilds it with a fresh code.3+- Tapping the 2FA chip fills the field in one motion, the same way as the username/password chip.4+- Creating a new login from an autofillable form lands you in the new-entry editor with the URL pre-filled (previously opened the random last-viewed entry).5+6+## Fixes7+- The "+ Save to Peach" chip no longer races the unlock screen — auth-then-create now lands on the new-entry editor with the origin URL prefilled instead of dropping you on a random EntryDetail.8+- 2FA / OTP chip detection now matches common labels in the wild (verification, security code, token, etc.).

view file