Skip to content

Commit 418a803

Browse files
authored
Merge pull request #2564 from SatoshiPortal/feat/custom-mnemonic-keyboard
feat(core): replace the OS keyboard in favor of an in-app keyboard on seed entry
2 parents c74bc0b + 26d9946 commit 418a803

11 files changed

Lines changed: 1421 additions & 509 deletions

File tree

lib/core/utils/bip39.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,36 @@ class Bip39WordList {
2323
return null;
2424
}
2525
}
26+
27+
/// The letters that can extend [prefix] toward at least one word of
28+
/// [language].
29+
///
30+
/// This is what lets the in-app keyboard shrink to only the keys that keep a
31+
/// word possible: an empty prefix yields every letter some word begins with
32+
/// (so a letter no word starts with is never offered), and a prefix like
33+
/// `'a'` excludes `'a'` itself because no wordlist word starts with `'aa'`.
34+
/// A prefix that is already a complete word with no longer word extending it
35+
/// yields the empty set — the keyboard then offers backspace only.
36+
///
37+
/// Deliberately answered against the **whole** wordlist even when used on the
38+
/// last field: narrowing to the checksum candidates would let an earlier typo
39+
/// that happens to be another valid word hide the real last word, turning the
40+
/// one error the checksum exists to catch into a silently restored wrong
41+
/// wallet. The candidate narrowing stays a guidance-only concern of the
42+
/// suggestion chips.
43+
///
44+
/// A linear scan of the 2048-word list per keystroke is negligible, so no
45+
/// trie is warranted.
46+
static Set<String> allowedNextLetters({
47+
required String prefix,
48+
bip39.Language language = bip39.Language.english,
49+
}) {
50+
final letters = <String>{};
51+
for (final word in language.list) {
52+
if (word.length > prefix.length && word.startsWith(prefix)) {
53+
letters.add(word[prefix.length]);
54+
}
55+
}
56+
return letters;
57+
}
2658
}

lib/core/widgets/inputs/labeled_text_input.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ class LabeledTextInput extends StatelessWidget {
1010
final Function(String)? onChanged;
1111
final int? maxLines;
1212

13+
/// Both default to true. Set them false for secrets: the IME's suggestion
14+
/// and autocorrect caches must never see the value.
15+
final bool enableSuggestions;
16+
final bool autocorrect;
17+
1318
const LabeledTextInput({
1419
super.key,
1520
required this.label,
1621
required this.value,
1722
required this.onChanged,
1823
this.hint = '',
1924
this.maxLines,
25+
this.enableSuggestions = true,
26+
this.autocorrect = true,
2027
});
2128

2229
@override
@@ -63,6 +70,8 @@ class LabeledTextInput extends StatelessWidget {
6370
hint: hint,
6471
hideBorder: true,
6572
maxLines: maxLines,
73+
enableSuggestions: enableSuggestions,
74+
autocorrect: autocorrect,
6675
),
6776
),
6877
],
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import 'package:bb_mobile/core/themes/app_theme.dart';
2+
import 'package:bb_mobile/core/widgets/text/text.dart';
3+
import 'package:flutter/material.dart';
4+
5+
/// Letters-only keyboard for mnemonic entry.
6+
///
7+
/// It exists to keep the recovery phrase off the platform IME: on the seed
8+
/// entry screen the word fields are read-only, and this widget is the only
9+
/// path from a tap to a character. A third-party keyboard, the OS
10+
/// autocorrect cache, and any accessibility keylogger therefore never see a
11+
/// keystroke of the seed.
12+
///
13+
/// Deliberately dumb: it knows nothing about BIP39. It renders the 26 letters
14+
/// of [layout] in three rows, enables a key only when its letter is in
15+
/// [enabledLetters], and reports taps through [onLetter] / [onBackspace]. All
16+
/// the wordlist intelligence — which letters keep a word possible, auto fill,
17+
/// focus advance — stays with the owner that computes [enabledLetters].
18+
///
19+
/// [layout] is just the display order: pass [qwerty] for a familiar keyboard,
20+
/// or a shuffled alphabet for the paranoid mode, where randomised key
21+
/// positions defeat shoulder-surfing and tap-position inference.
22+
class MnemonicKeyboard extends StatelessWidget {
23+
/// A familiar QWERTY order, split 10 / 9 / 7 across the three rows.
24+
static const List<String> qwerty = [
25+
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', //
26+
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', //
27+
'z', 'x', 'c', 'v', 'b', 'n', 'm', //
28+
];
29+
30+
/// The 26 lowercase letters in display order. Split 10 / 9 / 7 into rows.
31+
final List<String> layout;
32+
33+
/// The lowercase letters a tap may currently produce. A key outside this set
34+
/// is shown disabled: the owner has determined it cannot extend the word.
35+
final Set<String> enabledLetters;
36+
37+
/// Whether the backspace key is active — false only when the focused field
38+
/// is already empty, so there is nothing to delete.
39+
final bool canBackspace;
40+
41+
final void Function(String letter) onLetter;
42+
final VoidCallback onBackspace;
43+
44+
/// Paranoid mode toggle, shown as a key next to backspace.
45+
final bool shuffleActive;
46+
final VoidCallback onToggleShuffle;
47+
48+
/// Tooltip for the shuffle key. Passed in so this widget stays free of
49+
/// localization.
50+
final String shuffleHint;
51+
52+
const MnemonicKeyboard({
53+
super.key,
54+
required this.enabledLetters,
55+
required this.canBackspace,
56+
required this.onLetter,
57+
required this.onBackspace,
58+
required this.shuffleActive,
59+
required this.onToggleShuffle,
60+
required this.shuffleHint,
61+
this.layout = qwerty,
62+
}) : assert(
63+
layout.length == 26,
64+
'The keyboard lays out exactly 26 keys in three rows (10/9/7); '
65+
'any other length throws a RangeError at build time.',
66+
);
67+
68+
@override
69+
Widget build(BuildContext context) {
70+
return Material(
71+
color: context.appColors.surfaceContainer,
72+
child: SafeArea(
73+
top: false,
74+
child: Padding(
75+
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
76+
child: Column(
77+
mainAxisSize: MainAxisSize.min,
78+
children: [
79+
_LetterRow(
80+
letters: layout.sublist(0, 10),
81+
enabledLetters: enabledLetters,
82+
onLetter: onLetter,
83+
paranoid: shuffleActive,
84+
),
85+
_LetterRow(
86+
letters: layout.sublist(10, 19),
87+
enabledLetters: enabledLetters,
88+
onLetter: onLetter,
89+
paranoid: shuffleActive,
90+
),
91+
_LetterRow(
92+
letters: layout.sublist(19, 26),
93+
enabledLetters: enabledLetters,
94+
onLetter: onLetter,
95+
paranoid: shuffleActive,
96+
// Backspace shares its slot with the shuffle toggle
97+
trailing: Row(
98+
children: [
99+
Expanded(
100+
child: _BackspaceKey(
101+
enabled: canBackspace,
102+
onTap: onBackspace,
103+
),
104+
),
105+
Expanded(
106+
child: _ShuffleKey(
107+
active: shuffleActive,
108+
hint: shuffleHint,
109+
onTap: onToggleShuffle,
110+
),
111+
),
112+
],
113+
),
114+
),
115+
],
116+
),
117+
),
118+
),
119+
);
120+
}
121+
}
122+
123+
class _LetterRow extends StatelessWidget {
124+
final List<String> letters;
125+
final Set<String> enabledLetters;
126+
final void Function(String letter) onLetter;
127+
final bool paranoid;
128+
final Widget? trailing;
129+
130+
const _LetterRow({
131+
required this.letters,
132+
required this.enabledLetters,
133+
required this.onLetter,
134+
required this.paranoid,
135+
this.trailing,
136+
});
137+
138+
@override
139+
Widget build(BuildContext context) {
140+
return Padding(
141+
padding: const EdgeInsets.symmetric(vertical: 3),
142+
child: Row(
143+
children: [
144+
for (final letter in letters)
145+
Expanded(
146+
child: _LetterKey(
147+
letter: letter,
148+
enabled: enabledLetters.contains(letter),
149+
paranoid: paranoid,
150+
onTap: () => onLetter(letter),
151+
),
152+
),
153+
if (trailing != null) Expanded(flex: 2, child: trailing!),
154+
],
155+
),
156+
);
157+
}
158+
}
159+
160+
class _LetterKey extends StatelessWidget {
161+
final String letter;
162+
final bool enabled;
163+
final bool paranoid;
164+
final VoidCallback onTap;
165+
166+
const _LetterKey({
167+
required this.letter,
168+
required this.enabled,
169+
required this.paranoid,
170+
required this.onTap,
171+
});
172+
173+
@override
174+
Widget build(BuildContext context) {
175+
// ExcludeSemantics: the key's letter must not reach the accessibility tree,
176+
// where a malicious accessibility service would read the seed letter by
177+
// letter as it is typed. This makes the keyboard unusable with a screen
178+
// reader by design — the recovery phrase is too sensitive to narrate.
179+
return ExcludeSemantics(
180+
child: _KeyCap(
181+
enabled: enabled,
182+
onTap: onTap,
183+
suppressAnimation: paranoid,
184+
child: BBText(
185+
letter,
186+
style: context.font.headlineLarge,
187+
color: enabled
188+
? context.appColors.onSurface
189+
: context.appColors.textMuted,
190+
),
191+
),
192+
);
193+
}
194+
}
195+
196+
class _BackspaceKey extends StatelessWidget {
197+
final bool enabled;
198+
final VoidCallback onTap;
199+
200+
const _BackspaceKey({required this.enabled, required this.onTap});
201+
202+
@override
203+
Widget build(BuildContext context) {
204+
return _KeyCap(
205+
enabled: enabled,
206+
onTap: onTap,
207+
child: Icon(
208+
Icons.backspace_outlined,
209+
size: 20,
210+
color: enabled
211+
? context.appColors.onSurface
212+
: context.appColors.textMuted,
213+
),
214+
);
215+
}
216+
}
217+
218+
/// Toggles the paranoid, randomised-layout mode. Accent-coloured while active.
219+
class _ShuffleKey extends StatelessWidget {
220+
final bool active;
221+
final String hint;
222+
final VoidCallback onTap;
223+
224+
const _ShuffleKey({
225+
required this.active,
226+
required this.hint,
227+
required this.onTap,
228+
});
229+
230+
@override
231+
Widget build(BuildContext context) {
232+
return Tooltip(
233+
message: hint,
234+
child: _KeyCap(
235+
key: const Key('mnemonicParanoidToggle'),
236+
enabled: true,
237+
onTap: onTap,
238+
child: Icon(
239+
Icons.shuffle,
240+
size: 20,
241+
color: active
242+
? context.appColors.primary
243+
: context.appColors.onSurface,
244+
),
245+
),
246+
);
247+
}
248+
}
249+
250+
/// The shared key shell: sizing, colour, and tap surface. A disabled key has
251+
/// no tap handler at all, so it cannot fire even through automation.
252+
class _KeyCap extends StatelessWidget {
253+
final bool enabled;
254+
final VoidCallback onTap;
255+
final Widget child;
256+
257+
/// When true, the key changes appearance as an instant cut with no ink
258+
/// splash. Used while the layout is reshuffling on every tap: an animated
259+
/// colour fade or a splash that outlives the reshuffle would mark, for a
260+
/// frame, which slot was just pressed — letting an observer follow a letter
261+
/// across the shuffle and defeating it.
262+
final bool suppressAnimation;
263+
264+
const _KeyCap({
265+
super.key,
266+
required this.enabled,
267+
required this.onTap,
268+
required this.child,
269+
this.suppressAnimation = false,
270+
});
271+
272+
@override
273+
Widget build(BuildContext context) {
274+
return Padding(
275+
padding: const EdgeInsets.symmetric(horizontal: 2),
276+
child: Material(
277+
// Zero duration: even in basic mode an enable/disable colour tween is
278+
// an extra frame of state history for a camera; there is no reason to
279+
// animate a key cap.
280+
animationDuration: Duration.zero,
281+
color: enabled
282+
? context.appColors.surface
283+
: context.appColors.surfaceContainerHighest,
284+
borderRadius: BorderRadius.circular(6),
285+
child: InkWell(
286+
// A key must never take focus from the word field being typed into.
287+
canRequestFocus: false,
288+
borderRadius: BorderRadius.circular(6),
289+
splashFactory: suppressAnimation ? NoSplash.splashFactory : null,
290+
highlightColor: suppressAnimation ? Colors.transparent : null,
291+
onTap: enabled ? onTap : null,
292+
child: Container(
293+
height: 44,
294+
alignment: Alignment.center,
295+
child: child,
296+
),
297+
),
298+
),
299+
);
300+
}
301+
}

0 commit comments

Comments
 (0)