diff --git a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt index 25d906bf61..74ff5c2ddb 100644 --- a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt +++ b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt @@ -5,26 +5,48 @@ import androidx.compose.animation.core.EaseOutQuart import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import kotlin.math.roundToInt + +/** Font size a recovery phrase word starts from before shrinking to fit its row. */ +private val WORD_MAX_FONT_SIZE = AppTextStyles.BodyMSB.fontSize + +/** Smallest font size a recovery phrase word shrinks to. */ +private val WORD_MIN_FONT_SIZE = 12.sp + +/** Step used when shrinking recovery phrase words to fit. */ +private val WORD_FONT_SIZE_STEP = 0.5.sp + +/** Horizontal gap between the two word columns. */ +private val COLUMN_GAP = 32.dp + +/** Horizontal gap between a word number and the word. */ +private val LABEL_GAP = 8.dp @Composable fun MnemonicWordsGrid( @@ -40,12 +62,17 @@ fun MnemonicWordsGrid( animationSpec = tween(blurDurationMs, easing = EaseOutQuart), label = "blurRadius" ) - Box( + BoxWithConstraints( modifier = modifier .fillMaxWidth() .blur(radius = blurRadius.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) .alpha(alpha = 1f - blurRadius * 0.075f) ) { + val wordFit = rememberWordFontFit( + actualWords = actualWords, + placeholderWords = placeholderWords, + constraints = constraints, + ) Crossfade( targetState = showMnemonic, animationSpec = tween(crossfadeDurationMs), @@ -55,7 +82,7 @@ fun MnemonicWordsGrid( val half = wordsShown.size / 2 Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), modifier = Modifier.fillMaxWidth() ) { Column( @@ -66,6 +93,7 @@ fun MnemonicWordsGrid( WordItem( number = index + 1, word = word, + fit = wordFit, ) } } @@ -77,6 +105,7 @@ fun MnemonicWordsGrid( WordItem( number = half + index + 1, word = word, + fit = wordFit, ) } } @@ -85,22 +114,132 @@ fun MnemonicWordsGrid( } } +@Composable +private fun rememberWordFontFit( + actualWords: List, + placeholderWords: List, + constraints: Constraints, +): MnemonicFontFit { + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(actualWords, placeholderWords, constraints.maxWidth, constraints.hasBoundedWidth, density) { + if (!constraints.hasBoundedWidth) return@remember MnemonicFontFit(WORD_MAX_FONT_SIZE, fits = true) + val columnGapPx = with(density) { COLUMN_GAP.roundToPx() } + val labelGapPx = with(density) { LABEL_GAP.roundToPx() } + val budgetPx: (Int) -> Int = { number -> + val labelWidthPx = textMeasurer.measure( + text = "$number.", + style = AppTextStyles.BodyMSB, + maxLines = 1, + softWrap = false, + density = density, + ).size.width + mnemonicWordBudgetPx(constraints.maxWidth, columnGapPx, labelWidthPx, labelGapPx) + } + val measurePx: (String, TextUnit) -> Int = { word, fontSize -> + textMeasurer.measure( + text = word, + style = AppTextStyles.BodyMSB.copy(fontSize = fontSize), + maxLines = 1, + softWrap = false, + density = density, + ).size.width + } + fitSharedMnemonicFontSize( + wordLists = listOf(actualWords, placeholderWords), + wordBudgetPx = budgetPx, + measureWordPx = measurePx, + ) + } +} + +/** + * Font size shared by every word in the grid. When [fits] is false a word is still too wide at the + * minimum size, so words wrap instead of running past their column. + */ +internal data class MnemonicFontFit( + val fontSize: TextUnit, + val fits: Boolean, +) + +/** + * Returns the one size the grid shares across reveal states: the smallest size any of [wordLists] + * needs, wrapping when any list still does not fit at that size. Keeps the card height stable when + * the phrase is revealed, since the revealed words and the hidden placeholders render at one size. + */ +internal fun fitSharedMnemonicFontSize( + wordLists: List>, + wordBudgetPx: (Int) -> Int, + measureWordPx: (String, TextUnit) -> Int, +): MnemonicFontFit { + val fits = wordLists.map { fitMnemonicFontSize(it, wordBudgetPx, measureWordPx) } + return MnemonicFontFit( + fontSize = fits.minByOrNull { it.fontSize.value }?.fontSize ?: WORD_MAX_FONT_SIZE, + fits = fits.all { it.fits }, + ) +} + +/** + * Returns the largest font size, stepping down from 17sp to 12sp in 0.5sp steps, at which every word + * fits its row, so the whole grid shares one size. Falls back to 12sp with `fits = false` when a word + * does not fit even at 12sp. + * + * [wordBudgetPx] receives the 1-based word number and [measureWordPx] the word and candidate size. + */ +internal fun fitMnemonicFontSize( + words: List, + wordBudgetPx: (Int) -> Int, + measureWordPx: (String, TextUnit) -> Int, +): MnemonicFontFit { + val budgets = words.indices.map { wordBudgetPx(it + 1) } + val steps = ((WORD_MAX_FONT_SIZE.value - WORD_MIN_FONT_SIZE.value) / WORD_FONT_SIZE_STEP.value).roundToInt() + for (index in 0..steps) { + val fontSize = (WORD_MAX_FONT_SIZE.value - index * WORD_FONT_SIZE_STEP.value).sp + val allFit = words.indices.all { measureWordPx(words[it], fontSize) <= budgets[it] } + if (allFit) return MnemonicFontFit(fontSize, fits = true) + } + return MnemonicFontFit(WORD_MIN_FONT_SIZE, fits = false) +} + +/** Returns the width left for a word in one of the two grid columns after its number label. */ +internal fun mnemonicWordBudgetPx( + gridWidthPx: Int, + columnGapPx: Int, + labelWidthPx: Int, + labelGapPx: Int, +): Int = ((gridWidthPx - columnGapPx) / 2 - labelWidthPx - labelGapPx).coerceAtLeast(0) + @Composable private fun WordItem( number: Int, word: String, + fit: MnemonicFontFit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - BodyMSB(text = "$number.", color = Colors.White64) - Spacer(modifier = Modifier.width(8.dp)) - BodyMSB(text = word, color = Colors.White) + Row { + BodyMSB( + text = "$number.", + color = Colors.White64, + maxLines = 1, + modifier = Modifier.alignByBaseline() + ) + HorizontalSpacer(LABEL_GAP) + Text( + text = word, + style = AppTextStyles.BodyMSB.copy(color = Colors.White, fontSize = fit.fontSize), + maxLines = if (fit.fits) 1 else Int.MAX_VALUE, + softWrap = !fit.fits, + overflow = TextOverflow.Visible, + modifier = Modifier + .weight(1f) + .alignByBaseline() + ) } } private val previewWords = List(8) { "word${it + 1}" }.toImmutableList() +private val previewLongWords = listOf("abstract", "research", "awesome", "category") + @Preview @Composable private fun Preview() { @@ -122,3 +261,63 @@ private fun PreviewHidden() { ) } } + +@Preview(widthDp = 375) +@Composable +private fun PreviewLongWords12() { + AppThemeSurface { + MnemonicWordsGrid( + actualWords = List(12) { previewLongWords[it % previewLongWords.size] }.toImmutableList(), + showMnemonic = true, + modifier = Modifier.padding(horizontal = 64.dp) + ) + } +} + +@Preview(widthDp = 375) +@Composable +private fun PreviewLongWords24() { + AppThemeSurface { + MnemonicWordsGrid( + actualWords = List(24) { previewLongWords[it % previewLongWords.size] }.toImmutableList(), + showMnemonic = true, + modifier = Modifier.padding(horizontal = 64.dp) + ) + } +} + +@Preview(widthDp = 375, fontScale = 1.3f) +@Composable +private fun PreviewLongWords12FontScale() { + AppThemeSurface { + MnemonicWordsGrid( + actualWords = List(12) { previewLongWords[it % previewLongWords.size] }.toImmutableList(), + showMnemonic = true, + modifier = Modifier.padding(horizontal = 64.dp) + ) + } +} + +@Preview(widthDp = 375, fontScale = 1.3f) +@Composable +private fun PreviewLongWords24FontScale() { + AppThemeSurface { + MnemonicWordsGrid( + actualWords = List(24) { previewLongWords[it % previewLongWords.size] }.toImmutableList(), + showMnemonic = true, + modifier = Modifier.padding(horizontal = 64.dp) + ) + } +} + +@Preview(widthDp = 360, fontScale = 2f) +@Composable +private fun PreviewLongWords12FontScaleMax() { + AppThemeSurface { + MnemonicWordsGrid( + actualWords = List(12) { previewLongWords[it % previewLongWords.size] }.toImmutableList(), + showMnemonic = true, + modifier = Modifier.padding(horizontal = 64.dp) + ) + } +} diff --git a/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt new file mode 100644 index 0000000000..8a2bfd5b0b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt @@ -0,0 +1,161 @@ +package to.bitkit.ui.components + +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Regression pins for #633: long recovery phrase words must stay on one line. + * + * Widths are fake pixels: each character is as wide as the font size value, so a word fits when + * `length * fontSize <= budget`. + */ +class MnemonicWordsGridTest { + + private val measureWord: (String, TextUnit) -> Int = { word, fontSize -> (word.length * fontSize.value).toInt() } + + @Test + fun `short words keep the full font size`() { + val result = fitMnemonicFontSize( + words = listOf("cat", "dog", "sun"), + wordBudgetPx = { 100 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(17.sp, fits = true), result) + } + + @Test + fun `empty words keep the full font size`() { + val result = fitMnemonicFontSize( + words = emptyList(), + wordBudgetPx = { 0 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(17.sp, fits = true), result) + } + + @Test + fun `longest word picks the largest step that fits for the whole grid`() { + val result = fitMnemonicFontSize( + words = listOf("cat", "abstract", "dog"), + wordBudgetPx = { 110 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(13.5.sp, fits = true), result) + } + + @Test + fun `wider two digit labels shrink the budget of later words`() { + val words = List(12) { if (it == 11) "research" else "cat" } + + val result = fitMnemonicFontSize( + words = words, + wordBudgetPx = { number -> if (number >= 10) 104 else 136 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(13.sp, fits = true), result) + } + + @Test + fun `word that never fits falls back to the minimum font size and wrapping`() { + val result = fitMnemonicFontSize( + words = listOf("category"), + wordBudgetPx = { 10 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(12.sp, fits = false), result) + } + + @Test + fun `word that fits exactly at the minimum font size does not wrap`() { + val result = fitMnemonicFontSize( + words = listOf("category"), + wordBudgetPx = { 96 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(12.sp, fits = true), result) + } + + @Test + fun `word one pixel too wide at the minimum font size wraps`() { + val result = fitMnemonicFontSize( + words = listOf("cat", "category"), + wordBudgetPx = { 95 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(12.sp, fits = false), result) + } + + @Test + fun `actual words needing a smaller size set the shared size`() { + val result = fitSharedMnemonicFontSize( + wordLists = listOf(listOf("mushroom"), listOf("secret")), + wordBudgetPx = { 110 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(13.5.sp, fits = true), result) + } + + @Test + fun `placeholders needing a smaller size set the shared size`() { + val result = fitSharedMnemonicFontSize( + wordLists = listOf(listOf("cat"), listOf("secret")), + wordBudgetPx = { 100 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(16.5.sp, fits = true), result) + } + + @Test + fun `actual word that never fits wraps both reveal states`() { + val result = fitSharedMnemonicFontSize( + wordLists = listOf(listOf("category"), listOf("secret")), + wordBudgetPx = { 80 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(12.sp, fits = false), result) + } + + @Test + fun `placeholder that never fits wraps both reveal states`() { + val result = fitSharedMnemonicFontSize( + wordLists = listOf(listOf("cat"), listOf("secret")), + wordBudgetPx = { 60 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(12.sp, fits = false), result) + } + + @Test + fun `no word lists keep the full font size`() { + val result = fitSharedMnemonicFontSize( + wordLists = emptyList(), + wordBudgetPx = { 0 }, + measureWordPx = measureWord, + ) + + assertEquals(MnemonicFontFit(17.sp, fits = true), result) + } + + @Test + fun `word budget subtracts the column gap, label and label gap`() { + assertEquals(78, mnemonicWordBudgetPx(gridWidthPx = 247, columnGapPx = 32, labelWidthPx = 21, labelGapPx = 8)) + } + + @Test + fun `word budget is never negative`() { + assertEquals(0, mnemonicWordBudgetPx(gridWidthPx = 40, columnGapPx = 32, labelWidthPx = 21, labelGapPx = 8)) + } +} diff --git a/changelog.d/next/633.fixed.md b/changelog.d/next/633.fixed.md new file mode 100644 index 0000000000..102d98ea53 --- /dev/null +++ b/changelog.d/next/633.fixed.md @@ -0,0 +1 @@ +Long recovery phrase words now shrink to fit on one line instead of wrapping. diff --git a/journeys/README.md b/journeys/README.md index 0862fbbbae..fde37bd3b1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -117,6 +117,7 @@ fixtures, push notifications) live in each suite's README. | [activity](activity) | 1 | Date range sheet under rapid month taps; needs no backend, no README | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | +| [backup](backup) | 1 | Recovery phrase grid at larger system font scales; no README | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | @@ -151,6 +152,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `payment-requests/requested-resolution-failure.xml` | not ported | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | +| `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | diff --git a/journeys/backup/show-mnemonic-long-words.xml b/journeys/backup/show-mnemonic-long-words.xml new file mode 100644 index 0000000000..259422230d --- /dev/null +++ b/journeys/backup/show-mnemonic-long-words.xml @@ -0,0 +1,54 @@ + + + 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. + + + Run adb shell pm clear to.bitkit.dev + Run adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 + On the terms screen, tap both checkboxes (testTags "Check1" and "Check2"), then tap "Continue" (testTag "Continue") + Tap "Skip" (testTag "SkipIntro") to reach the last onboarding slide + Tap "Restore" (testTag "RestoreWallet"), then confirm the multiple-devices warning (testTag "MultipleDevices-button") + 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 + Verify no field is marked invalid and no checksum error is shown, then tap "Restore" (testTag "RestoreButton") + 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 + Run adb shell settings put system font_scale 1.3 + Tap the menu icon (testTag "HeaderMenu") + Tap "Settings" (testTag "DrawerSettings") + Tap the Security tab (testTag "Tab-security") + Tap "Back up your wallet" (testTag "BackupWallet") + 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") + Tap "Tap To Reveal" + Verify the words box lists 12 number labels, "1." to "12.", grouped in 6 rows of 2 with the same vertical center per row + 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 + 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 + Press back to close the sheet + Run adb shell settings put system font_scale 1.0 + +