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
↓ peach-android-0f4da033.tar.gzatlas at this save
files changed14 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 −0app/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" />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 }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+}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 }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")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 }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 {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) {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 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+}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 }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+}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.).