From 834ddded9315191d9539667834bb985c93d58fae Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 13:07:20 -0300 Subject: [PATCH 1/6] fix: fit long mnemonic words on one line Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/ui/components/MnemonicWordsGrid.kt | 182 ++++++++++++++++-- .../ui/components/MnemonicWordsGridTest.kt | 84 ++++++++ changelog.d/next/633.fixed.md | 1 + 3 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt create mode 100644 changelog.d/next/633.fixed.md 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..32e5c58e7b 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 wordFontSize = rememberWordFontSize( + 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, + fontSize = wordFontSize, ) } } @@ -77,6 +105,7 @@ fun MnemonicWordsGrid( WordItem( number = half + index + 1, word = word, + fontSize = wordFontSize, ) } } @@ -85,22 +114,103 @@ fun MnemonicWordsGrid( } } +@Composable +private fun rememberWordFontSize( + actualWords: List, + placeholderWords: List, + constraints: Constraints, +): TextUnit { + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(actualWords, placeholderWords, constraints.maxWidth, constraints.hasBoundedWidth, density) { + if (!constraints.hasBoundedWidth) return@remember WORD_MAX_FONT_SIZE + 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 + } + listOf(actualWords, placeholderWords) + .map { fitMnemonicFontSize(it, budgetPx, measurePx) } + .minBy { it.value } + } +} + +/** + * 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 when nothing fits. + * + * [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, +): TextUnit { + 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 fontSize + } + return WORD_MIN_FONT_SIZE +} + +/** 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, + fontSize: TextUnit, ) { - 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 = fontSize), + maxLines = 1, + softWrap = false, + 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 +232,51 @@ 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) + ) + } +} 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..911ed7c0e3 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt @@ -0,0 +1,84 @@ +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(17.sp, result) + } + + @Test + fun `empty words keep the full font size`() { + val result = fitMnemonicFontSize( + words = emptyList(), + wordBudgetPx = { 0 }, + measureWordPx = measureWord, + ) + + assertEquals(17.sp, 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(13.5.sp, 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(13.sp, result) + } + + @Test + fun `word that never fits falls back to the minimum font size`() { + val result = fitMnemonicFontSize( + words = listOf("category"), + wordBudgetPx = { 10 }, + measureWordPx = measureWord, + ) + + assertEquals(12.sp, 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. From 3faa8e72d4cca8dd8f34aa76eee7f6822044f269 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 13:12:00 -0300 Subject: [PATCH 2/6] fix: wrap mnemonic words that do not fit at min size Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/ui/components/MnemonicWordsGrid.kt | 58 +++++++++++++------ .../ui/components/MnemonicWordsGridTest.kt | 34 +++++++++-- 2 files changed, 69 insertions(+), 23 deletions(-) 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 32e5c58e7b..b8db145435 100644 --- a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt +++ b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt @@ -68,7 +68,7 @@ fun MnemonicWordsGrid( .blur(radius = blurRadius.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) .alpha(alpha = 1f - blurRadius * 0.075f) ) { - val wordFontSize = rememberWordFontSize( + val wordFit = rememberWordFontFit( actualWords = actualWords, placeholderWords = placeholderWords, constraints = constraints, @@ -93,7 +93,7 @@ fun MnemonicWordsGrid( WordItem( number = index + 1, word = word, - fontSize = wordFontSize, + fit = wordFit, ) } } @@ -105,7 +105,7 @@ fun MnemonicWordsGrid( WordItem( number = half + index + 1, word = word, - fontSize = wordFontSize, + fit = wordFit, ) } } @@ -115,15 +115,15 @@ fun MnemonicWordsGrid( } @Composable -private fun rememberWordFontSize( +private fun rememberWordFontFit( actualWords: List, placeholderWords: List, constraints: Constraints, -): TextUnit { +): MnemonicFontFit { val textMeasurer = rememberTextMeasurer() val density = LocalDensity.current return remember(actualWords, placeholderWords, constraints.maxWidth, constraints.hasBoundedWidth, density) { - if (!constraints.hasBoundedWidth) return@remember WORD_MAX_FONT_SIZE + 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 -> @@ -145,15 +145,27 @@ private fun rememberWordFontSize( density = density, ).size.width } - listOf(actualWords, placeholderWords) - .map { fitMnemonicFontSize(it, budgetPx, measurePx) } - .minBy { it.value } + val fits = listOf(actualWords, placeholderWords).map { fitMnemonicFontSize(it, budgetPx, measurePx) } + MnemonicFontFit( + fontSize = fits.minBy { it.fontSize.value }.fontSize, + fits = fits.all { it.fits }, + ) } } +/** + * 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 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 when nothing fits. + * 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. */ @@ -161,15 +173,15 @@ internal fun fitMnemonicFontSize( words: List, wordBudgetPx: (Int) -> Int, measureWordPx: (String, TextUnit) -> Int, -): TextUnit { +): 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 fontSize + if (allFit) return MnemonicFontFit(fontSize, fits = true) } - return WORD_MIN_FONT_SIZE + 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. */ @@ -184,7 +196,7 @@ internal fun mnemonicWordBudgetPx( private fun WordItem( number: Int, word: String, - fontSize: TextUnit, + fit: MnemonicFontFit, ) { Row { BodyMSB( @@ -196,9 +208,9 @@ private fun WordItem( HorizontalSpacer(LABEL_GAP) Text( text = word, - style = AppTextStyles.BodyMSB.copy(color = Colors.White, fontSize = fontSize), - maxLines = 1, - softWrap = false, + 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) @@ -280,3 +292,15 @@ private fun PreviewLongWords24FontScale() { ) } } + +@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 index 911ed7c0e3..aa55d70c15 100644 --- a/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt +++ b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt @@ -23,7 +23,7 @@ class MnemonicWordsGridTest { measureWordPx = measureWord, ) - assertEquals(17.sp, result) + assertEquals(MnemonicFontFit(17.sp, fits = true), result) } @Test @@ -34,7 +34,7 @@ class MnemonicWordsGridTest { measureWordPx = measureWord, ) - assertEquals(17.sp, result) + assertEquals(MnemonicFontFit(17.sp, fits = true), result) } @Test @@ -45,7 +45,7 @@ class MnemonicWordsGridTest { measureWordPx = measureWord, ) - assertEquals(13.5.sp, result) + assertEquals(MnemonicFontFit(13.5.sp, fits = true), result) } @Test @@ -58,18 +58,40 @@ class MnemonicWordsGridTest { measureWordPx = measureWord, ) - assertEquals(13.sp, result) + assertEquals(MnemonicFontFit(13.sp, fits = true), result) } @Test - fun `word that never fits falls back to the minimum font size`() { + 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(12.sp, result) + 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 From 39373f24c3d70068cc202758198b56c5cf0077f3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 20:16:39 -0300 Subject: [PATCH 3/6] test: cover shared mnemonic font size selection Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/ui/components/MnemonicWordsGrid.kt | 25 +++++++-- .../ui/components/MnemonicWordsGridTest.kt | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) 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 b8db145435..74ff5c2ddb 100644 --- a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt +++ b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt @@ -145,10 +145,10 @@ private fun rememberWordFontFit( density = density, ).size.width } - val fits = listOf(actualWords, placeholderWords).map { fitMnemonicFontSize(it, budgetPx, measurePx) } - MnemonicFontFit( - fontSize = fits.minBy { it.fontSize.value }.fontSize, - fits = fits.all { it.fits }, + fitSharedMnemonicFontSize( + wordLists = listOf(actualWords, placeholderWords), + wordBudgetPx = budgetPx, + measureWordPx = measurePx, ) } } @@ -162,6 +162,23 @@ internal data class MnemonicFontFit( 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 diff --git a/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt index aa55d70c15..8a2bfd5b0b 100644 --- a/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt +++ b/app/src/test/java/to/bitkit/ui/components/MnemonicWordsGridTest.kt @@ -94,6 +94,61 @@ class MnemonicWordsGridTest { 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)) From 535f6c0224cb87feea38a1b6003da79f05eb2616 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:41:41 -0300 Subject: [PATCH 4/6] docs: add show mnemonic long words journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ journeys/backup/show-mnemonic-long-words.xml | 31 ++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 journeys/backup/show-mnemonic-long-words.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..30c70be0f8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -115,6 +115,7 @@ fixtures, push notifications) live in each suite's README. | Suite | Journeys | Notes | | --- | --- | --- | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | +| [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 | @@ -141,6 +142,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `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 | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/backup/show-mnemonic-long-words.xml b/journeys/backup/show-mnemonic-long-words.xml new file mode 100644 index 0000000000..5559bf26ec --- /dev/null +++ b/journeys/backup/show-mnemonic-long-words.xml @@ -0,0 +1,31 @@ + + + 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: onboarded dev wallet with a 12-word phrase and no PIN (with a PIN, enter the + correct PIN when prompted and never a wrong one). Start on the wallet home screen. Restore + font_scale to 1.0 when done. + + + 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 the vertical distance between consecutive rows is equal, so no word wraps onto a second line + Press back to close the sheet + Run adb shell settings put system font_scale 1.0 + + From 24117c6e4b0906955e011a678f0b3d7b5cce7968 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:14:19 -0300 Subject: [PATCH 5/6] docs: make mnemonic long words journey discriminating Co-Authored-By: Claude Opus 5 (1M context) --- journeys/backup/show-mnemonic-long-words.xml | 31 +++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/journeys/backup/show-mnemonic-long-words.xml b/journeys/backup/show-mnemonic-long-words.xml index 5559bf26ec..4f1e61eaa8 100644 --- a/journeys/backup/show-mnemonic-long-words.xml +++ b/journeys/backup/show-mnemonic-long-words.xml @@ -11,11 +11,33 @@ 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: onboarded dev wallet with a 12-word phrase and no PIN (with a PIN, enter the - correct PIN when prompted and never a wrong one). Start on the wallet home screen. Restore - font_scale to 1.0 when done. + 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, tap "Proceed Without Backup" (testTag "ProceedWithoutBackupButton") and confirm — 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") @@ -24,7 +46,8 @@ 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 the vertical distance between consecutive rows is equal, so no word wraps onto a second line + 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 From d2149c174ad50047ec51d5c8cc49ef416b5350b0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:37:22 -0300 Subject: [PATCH 6/6] docs: fix restore failure fallback in mnemonic journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/backup/show-mnemonic-long-words.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/backup/show-mnemonic-long-words.xml b/journeys/backup/show-mnemonic-long-words.xml index 4f1e61eaa8..259422230d 100644 --- a/journeys/backup/show-mnemonic-long-words.xml +++ b/journeys/backup/show-mnemonic-long-words.xml @@ -37,7 +37,7 @@ 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, tap "Proceed Without Backup" (testTag "ProceedWithoutBackupButton") and confirm — the phrase has no backup and the on-chain wallet is what this journey needs + 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")