Skip to content

fix: fit long mnemonic words on one line - #1309

Open
jvsena42 wants to merge 9 commits into
masterfrom
fix/633-fit-mnemonic-words
Open

jvsena42 wants to merge 9 commits into
masterfrom
fix/633-fit-mnemonic-words

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 17, 2026

Copy link
Copy Markdown
Member

Fixes #633
Related to #1275

This PR fits long recovery phrase words on one line in the mnemonic grid instead of breaking them mid-word.

Description

  • Shrinks all words in the grid to one shared font size, from 17sp down to 12sp in 0.5sp steps, so that long words such as mushroom or mountain next to the two-digit labels no longer wrap at larger font scales.
  • Keeps the number labels at full size and aligns labels and words by baseline, so rows stay aligned when words render smaller.
  • Computes the size for both the revealed words and the hidden placeholders and uses the smaller one, so the card does not change height when the phrase is revealed.
  • Falls back to the previous wrapping behaviour when a word does not fit even at 12sp (for example font scale 2.0), so words stay fully visible inside their column instead of overflowing or being clipped.
  • Leaves the default font scale unchanged: short words keep 17sp and the grid matches the design.
  • Applies to both the backup and the recovery mnemonic screens through the shared grid component.

Out of Scope

  • MnemonicWordsGrid.kt: no shrinking below 12sp; at extreme font scales words still wrap inside their column.
  • MnemonicWordsGrid.kt: no Compose UI instrumented test for the composable wiring; sizing logic is covered by unit tests and was checked on device.
  • MnemonicWordsGrid.kt: font size measurement runs during composition; not optimised for the worst case (24 words at large font scales).
  • iOS parity: check whether bitkit-ios wraps long mnemonic words at larger text sizes; not addressed here.

Design

The design only shows short words at the default font scale. At font scale 1.0 the build matches it.

Preview

The screen sets FLAG_SECURE, so these captures come from temporary uncommitted builds with screenshot blocking removed and BIP39 test words (abstract awesome category mushroom mosquito document multiply mechanic marriage mountain awesome mushroom).

Figma Before, font scale 1.3 After, font scale 1.3
Figma Backup Recovery Phrase Displayed Before font scale 1.3 revealed After font scale 1.3 revealed
Before, font scale 1.3, hidden After, font scale 1.3, hidden
Before font scale 1.3 hidden After font scale 1.3 hidden
Before, font scale 1.0 After, font scale 1.0 After, font scale 2.0 (wrap fallback)
Before font scale 1.0 revealed After font scale 1.0 revealed After font scale 2.0 revealed

Reveal transition at font scale 1.3, no layout jump:

after-fs1.3-reveal.mp4

QA Notes

Manual Tests

  • 1a. System font scale 1.3 → Settings → Security → Back up wallet → Show Mnemonic → Tap To Reveal: every word stays on one line, including words next to 10., 11. and 12.
    • 1b. Hidden vs revealed: card height does not change on reveal.
  • 2. regression: System font scale 1.0 → Show Mnemonic → reveal: words render at the default size, matching the design.
  • 3. System font scale 2.0 → Show Mnemonic → reveal: words too wide for the column wrap inside it, none overflow into the next column or get clipped.
  • 4. regression: Recovery → Recovery Mnemonic: phrase grid renders correctly.

The steps of test 1 were run on a Pixel 9 emulator with journeys/backup/show-mnemonic-long-words.xml, committed in this PR and listed in journeys/README.md. The journey restores a fixed public BIP39 test phrase first, so the long words it checks are guaranteed to be there rather than left to whatever phrase the device happens to hold, and it wipes the app to do so — run it on a throwaway emulator. Its contents are inlined below.

show-mnemonic-long-words.xml
diff --git a/journeys/backup/show-mnemonic-long-words.xml b/journeys/backup/show-mnemonic-long-words.xml
new file mode 100644
index 000000000..259422230
--- /dev/null
+++ b/journeys/backup/show-mnemonic-long-words.xml
@@ -0,0 +1,54 @@
+<journey name="show mnemonic long words">
+  <description>
+    Verifies that every recovery phrase word on the backup screen renders on a single line at a
+    larger system font scale, including words next to the two-digit labels 10., 11. and 12.
+    (synonymdev/bitkit-android#633). The grid shrinks all words to one shared size, from 17sp down
+    to 12sp; only a word that is still too wide at 12sp wraps, which happens at extreme font scales
+    such as 2.0.
+
+    The screen sets FLAG_SECURE, so screenshots and recordings come out black, and the SeedContainer
+    content description holds the phrase. Never print, log, save or quote the words: the word
+    elements carry no testTag, so read rows from the number labels ("1." to "12.") and from the
+    bounds of the word elements, never their text or content-desc.
+
+    Precondition: the phrase must be known to contain long words, or the check passes for the wrong
+    reason. A random 12-word phrase has an 8-letter word next to a two-digit label only about one
+    time in eight, and on an unfixed build every other phrase renders on one line anyway. So the
+    journey restores a fixed phrase rather than revealing whatever the device already holds:
+
+      abstract awesome category mushroom mosquito document multiply mechanic marriage mountain
+      awesome mushroom
+
+    That is a public BIP39 test vector, not anyone's wallet. Its checksum is valid, so it restores.
+    It puts 8-letter words in slots 10 and 12, the two-digit positions this journey is about. It is
+    public, so anyone can spend from it: dev flavor (to.bitkit.dev, regtest) only, and never send it
+    funds.
+
+    Restoring replaces the wallet on the device — run this on a throwaway emulator, or on a device
+    whose wallet you are willing to lose. Steps 1-8 do the restore; skip them only if the device
+    already holds this exact phrase. No PIN is set after a fresh restore; if one is set, enter the
+    correct PIN when prompted and never a wrong one. Restore font_scale to 1.0 when done.
+  </description>
+  <actions>
+    <action>Run adb shell pm clear to.bitkit.dev</action>
+    <action>Run adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1</action>
+    <action>On the terms screen, tap both checkboxes (testTags "Check1" and "Check2"), then tap "Continue" (testTag "Continue")</action>
+    <action>Tap "Skip" (testTag "SkipIntro") to reach the last onboarding slide</action>
+    <action>Tap "Restore" (testTag "RestoreWallet"), then confirm the multiple-devices warning (testTag "MultipleDevices-button")</action>
+    <action>Enter the 12 words of the phrase above one field at a time, tapping each field (testTags "Word-0" through "Word-11") and typing only that one word — never paste or type the whole phrase in one go, adb drops characters from long strings</action>
+    <action>Verify no field is marked invalid and no checksum error is shown, then tap "Restore" (testTag "RestoreButton")</action>
+    <action>Wait for the restore to finish and tap "Get Started" (testTag "GetStartedButton"); if the backup restore fails instead, the failure screen offers only "Try Again" (testTag "TryAgainButton") — tap it until "Proceed Without Backup" (testTag "ProceedWithoutBackupButton") also appears, which takes two taps, then tap it and confirm with "Yes, Proceed" (testTag "DialogConfirm") — the phrase has no backup and the on-chain wallet is what this journey needs</action>
+    <action>Run adb shell settings put system font_scale 1.3</action>
+    <action>Tap the menu icon (testTag "HeaderMenu")</action>
+    <action>Tap "Settings" (testTag "DrawerSettings")</action>
+    <action>Tap the Security tab (testTag "Tab-security")</action>
+    <action>Tap "Back up your wallet" (testTag "BackupWallet")</action>
+    <action>Verify the recovery phrase screen shows "Tap To Reveal" (testTag "TapToReveal") inside the reveal overlay (testTag "SeedContainer") covering the words box (testTag "backup_mnemonic_words_box")</action>
+    <action>Tap "Tap To Reveal"</action>
+    <action>Verify the words box lists 12 number labels, "1." to "12.", grouped in 6 rows of 2 with the same vertical center per row</action>
+    <action>Verify every word element inside the words box is no taller than the number label beside it. A word element is the element to the right of a number label in the same row; identify it by position and compare bounds heights only, never its text. Labels always render one line at the full size while words shrink, so a word element taller than its label has wrapped onto a second line. This is the check that matters: it catches a wrap in any slot, including 6 and 12</action>
+    <action>Verify the vertical distance between consecutive rows is equal. This is a weaker, secondary check — the two columns lay out independently and the number label sits on the word's first-line baseline, so a wrap in the last slot of a column (6 or 12) shifts nothing below it and leaves every label position unchanged. Do not treat equal pitch on its own as proof that no word wrapped</action>
+    <action>Press back to close the sheet</action>
+    <action>Run adb shell settings put system font_scale 1.0</action>
+  </actions>
+</journey>

Automated Checks

  • Unit tests added: cover the per-list font size selection (short words keep 17sp, longest word sets the grid size, two-digit labels reduce the width, exact fit at 12sp stays on one line, 1px over 12sp falls back to wrapping), the size shared between the revealed words and the hidden placeholders (either list can set the size, either list failing at 12sp wraps both), and the row width arithmetic in MnemonicWordsGridTest.kt.
  • Ran locally: just compile, just test (2608 passed), just lint.

jvsena42 and others added 2 commits September 17, 2026 13:07
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42 jvsena42 self-assigned this Sep 17, 2026
@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking opportunity to test the shared hidden/revealed font-size selection.

Findings

  1. P2 Shared sizing lacks test coverage

Summary

Updates the shared mnemonic grid to choose one font size for all words, accounting for both the real phrase and hidden placeholders.

  • Measures available width after number labels and spacing, reducing word text from 17sp to 12sp in 0.5sp steps.
  • Keeps labels full-size, aligns text by baseline, and retains wrapping when the minimum size cannot fit.
  • Adds long-word previews and unit tests for font selection and width arithmetic.
  • No blocking behavioral issue identified. Additional unit coverage for the hidden/revealed selection would strengthen regression protection.

Reviews (1) · Last reviewed commit: "fix: wrap mnemonic words that do not fit..."

Comment thread app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 19e73e8 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no HIGH/MEDIUM findings at head 39373f24c. (Posted as a comment because GitHub does not allow approving your own PR.)

Checked: Traced the fit path end to end against Compose 1.12.0 sources (foundation LayoutUtils.finalMaxWidth, ui-text TextMeasurer/AndroidParagraph, material3 Text). On the fits=true path WordItem renders the word with softWrap=false, maxLines=1, overflow=Visible and weight(1f) (MnemonicWordsGrid.kt:226-235); with softWrap=false Compose gives the paragraph width max(ceil(maxIntrinsicWidth), minWidth), so a word is never broken or dropped, and a measurement mismatch could only overflow visibly into the 32dp column gap, never truncate. The measurement in rememberWordFontFit uses the same TextStyle (BodyMSB.copy(fontSize)), the same LocalDensity (font scale included) and the same font resolver as the rendered Text; material3 Text does not merge LocalTextStyle when a style is passed, InterFontFamily is a blocking resource font, so measured widths equal laid-out widths. Budget arithmetic is conservative: (gridWidth - gap)/2 floors while Row weight distribution gives each column floor or ceil, and per-number label widths are measured for 1..24 so two-digit labels in 24-word grids reduce the budget of the right words. On the fits=false fallback (font scale 2.0 or narrower) softWrap=true with maxLines=MAX and overflow=Visible keeps every character inside its column across lines, identical to master's behaviour, and the parent Box clip in ShowMnemonicScreen/RecoveryMnemonicScreen sits 32dp outside the grid. BoxWithConstraints always sees bounded width in both callers (verticalScroll Column, no IntrinsicSize ancestors), remember keys cover words, width and density, and both callers (backup sheet and recovery screen) go through the shared grid.

@jvsena42
jvsena42 requested a review from ovitrif September 18, 2026 13:26
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no blocking HIGH/MEDIUM findings at head 535f6c022. (Posted as a comment because GitHub does not allow approving your own PR.)

Inline: MEDIUM (journey, non-blocking) — Journey runs on whatever phrase the wallet has, so it can pass with the bug reverted; LOW (journey, non-blocking) — Label-position checks cannot see a wrap in words 6 and 12 at head

Checked: Round 3 (535f6c0, new journey): Reviewed the round-3 delta: the new journeys/backup/show-mnemonic-long-words.xml and the two journeys/README.md rows. Every testTag the journey names exists at head: HeaderMenu (HomeScreen.kt:1402), DrawerSettings (DrawerMenu.kt:311), Tab-security (CustomTabRowWithSpacing.kt:58 via SettingsTab.Security), BackupWallet (SettingsScreen.kt:419), backup_mnemonic_words_box, SeedContainer and TapToReveal (ShowMnemonicScreen.kt:165/178/189). The flow order matches the code: BackupWalletClick opens Sheet.Backup, whose default route is BackupRoute.ShowMnemonic (SheetHost.kt:81), so the reveal screen is the first screen of the sheet. The visible texts "Back up your wallet" and "Tap To Reveal" match strings.xml, and the FLAG_SECURE note and the SeedContainer content-description warning match ShowMnemonicScreen.kt. No bitkit-ios twin exists, and the skip is recorded in the cross-platform table.

Comment thread journeys/backup/show-mnemonic-long-words.xml Outdated
Comment thread journeys/backup/show-mnemonic-long-words.xml Outdated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no blocking HIGH/MEDIUM findings at head 24117c6e4. (Posted as a comment because GitHub does not allow approving your own PR.)

Inline: LOW — Proceed Without Backup is not shown on the first restore failure

Checked: Round 4 (24117c6): both earlier journey findings are fixed. Round 4 (24117c6): both earlier journey findings are fixed. The journey now wipes to.bitkit.dev and restores the fixed phrase abstract awesome category mushroom mosquito document multiply mechanic marriage mountain awesome mushroom. I recomputed its BIP39 checksum against the English wordlist (checksum bits 1110 = 1110), so it restores. Every slot except 2 and 11 holds an 8-letter word, including 10 and 12, and the description labels it a public test vector for the dev flavor and regtest only, never to be funded. The restore steps follow MainActivity's startup graph (Terms -> Intro skip -> Slides(LAST_SLIDE_INDEX) -> WarningMultipleDevices -> Restore), and every tag they name exists at head: Check1/Check2/Continue (TermsOfUseScreen.kt:91/99/109), SkipIntro (IntroScreen.kt:92), RestoreWallet (CreateWalletScreen.kt:90), MultipleDevices-button (WarningMultipleDevicesScreen.kt:70), Word-$index (RestoreWalletScreen.kt:416), RestoreButton (:301), GetStartedButton (WalletRestoreSuccessView.kt:59). The new height check is sound. In WordItem (MnemonicWordsGrid.kt:218-240) the label is BodyMSB at full size with maxLines=1. The word uses the same style at a size of 17sp or less, so one line is never taller than the label and any wrap is, in every slot including 6 and 12.

Comment thread journeys/backup/show-mnemonic-long-words.xml Outdated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — no blocking HIGH/MEDIUM findings at head d2149c174. (Posted as a comment because GitHub does not allow approving your own PR.)

Checked: Follow-up: Reviewed d2149c1 only. The fallback now matches the restore error flow: WalletRestoreErrorView renders only when the node is ErrorStarting (ContentView.kt:405-407), ProceedWithoutBackupButton shows only when retryCount > 1 (WalletRestoreErrorView.kt:78-84), and onRestoreRetry advances Initial -> Retry(1) -> Retry(2) via countRetry() (WalletViewModel.kt:263-264, 622), so two failed Try Again taps are needed before Proceed appears. TryAgainButton (WalletRestoreErrorView.kt:75) and the confirm tag DialogConfirm (AppAlertDialog.kt:79) exist at head, and the dialog's confirm invokes onProceedWithoutRestore, which settles the restore state and lands on the wallet.

ovi-reviewer[bot]

This comment was marked as resolved.

ovi-reviewer[bot]

This comment was marked as resolved.

ovitrif

This comment was marked as outdated.

ovitrif

This comment was marked as resolved.

@ovitrif

ovitrif commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

@jvsena42 e2e red + conflicts

@jvsena42

Copy link
Copy Markdown
Member Author

Approved, thanks. Both points:

Conflicts: the shared journeys/README.md row again — #1306 merged a few minutes ago and re-conflicted the batch. A merge of current master is running on this branch now, with the two stack bases included so #1288 and #1310 clear as well.

e2e: onchain_boost_receive_widgets is the shard whose genuine failure belongs to #1299 (the confirmed-only receive sheet covering the header), fixed on the e2e side in synonymdev/bitkit-e2e-tests#231. On this branch it is the usual setup flake. I will confirm from the re-run on the merged head rather than from the current commit.

Context on the churn is in #1275 (comment) — including the option of pulling the README rows out of every PR into one follow-up, if the team would rather stop the re-merges.

ovi-reviewer[bot]

This comment was marked as resolved.

ovi-reviewer[bot]

This comment was marked as resolved.

ovi-reviewer[bot]

This comment was marked as resolved.

@jvsena42
jvsena42 requested a review from ovitrif September 18, 2026 20:19

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ✅ Approve


Reaudit: diff 1 file.

Findings:
N/A

Audit:
Already done in comment.

QA: emu-1 Pixel 10 Pro emulator on Android 16

  1. passed: At 1.3 scale, all 12 words stayed single-line with uniform row spacing.

    Test 1:
    **1a.** System font scale 1.3 → Settings → Security → Back up wallet → Show Mnemonic → Tap To Reveal: every…
    1.mp4
  2. passed: Reveal preserved card height at 1.3; all 12 pairs matched default sizing at 1.0.

    Test 2:
    `regression:` System font scale 1.0 → Show Mnemonic → reveal: words render at the default size, matching the…
    2.mp4
  3. passed: At 2.0 scale, wide words wrapped within their columns without clipping.

    Test 3:
    System font scale 2.0 → Show Mnemonic → reveal: words too wide for the column wrap inside it, none overflow…
    3.mp4
  4. passed: Recovery Mnemonic rendered all 12 aligned pairs inside the grid.

    Test 4:
    `regression:` Recovery → Recovery Mnemonic: phrase grid renders correctly.
    4.mp4

Tip

Test 1 worth a journey:

  • Clear app data and restore the fixed public test wallet
  • Set system font scale to 1.3
  • Go to Settings → Security → Back up your wallet
  • Tap Tap To Reveal
  • Verify all 12 words stay on one line and rows remain evenly spaced
  • Return and reset system font scale to 1.0

Test 2 worth a journey:

  • Set system font scale to 1.3
  • Go to Settings → Security → Back up your wallet
  • Record the hidden recovery phrase card bounds
  • Tap Tap To Reveal and verify the card bounds stay unchanged
  • Return and set system font scale to 1.0
  • Open Back up your wallet and tap Tap To Reveal
  • Verify all words and number labels render at the same default size

Test 3 worth a journey:

  • Set system font scale to 2.0
  • Go to Settings → Security → Back up your wallet
  • Tap Tap To Reveal
  • Scroll the recovery phrase sheet until all rows are exposed
  • Verify wide words wrap inside their own column without clipping
  • Return and reset system font scale to 1.0

Test 4 worth a journey:

  • Open the Recovery screen
  • Tap Show Seed Phrase
  • Verify Recovery Mnemonic shows 12 aligned words inside the phrase grid
  • Press back to return to Recovery

Coverage:
QA: 5 of 5 manual tests passed


Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: GUI: Show seed phrase screen breaks line on long word

2 participants