Skip to content

Commit e59680a

Browse files
feat: add an in-app keyboard widget for import mnemonic
1 parent d8f0255 commit e59680a

9 files changed

Lines changed: 1281 additions & 502 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
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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+
});
63+
64+
@override
65+
Widget build(BuildContext context) {
66+
return Material(
67+
color: context.appColors.surfaceContainer,
68+
child: SafeArea(
69+
top: false,
70+
child: Padding(
71+
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
72+
child: Column(
73+
mainAxisSize: MainAxisSize.min,
74+
children: [
75+
_LetterRow(
76+
letters: layout.sublist(0, 10),
77+
enabledLetters: enabledLetters,
78+
onLetter: onLetter,
79+
paranoid: shuffleActive,
80+
),
81+
_LetterRow(
82+
letters: layout.sublist(10, 19),
83+
enabledLetters: enabledLetters,
84+
onLetter: onLetter,
85+
paranoid: shuffleActive,
86+
),
87+
_LetterRow(
88+
letters: layout.sublist(19, 26),
89+
enabledLetters: enabledLetters,
90+
onLetter: onLetter,
91+
paranoid: shuffleActive,
92+
// Backspace shares its slot with the shuffle toggle
93+
trailing: Row(
94+
children: [
95+
Expanded(
96+
child: _BackspaceKey(
97+
enabled: canBackspace,
98+
onTap: onBackspace,
99+
),
100+
),
101+
Expanded(
102+
child: _ShuffleKey(
103+
active: shuffleActive,
104+
hint: shuffleHint,
105+
onTap: onToggleShuffle,
106+
),
107+
),
108+
],
109+
),
110+
),
111+
],
112+
),
113+
),
114+
),
115+
);
116+
}
117+
}
118+
119+
class _LetterRow extends StatelessWidget {
120+
final List<String> letters;
121+
final Set<String> enabledLetters;
122+
final void Function(String letter) onLetter;
123+
final bool paranoid;
124+
final Widget? trailing;
125+
126+
const _LetterRow({
127+
required this.letters,
128+
required this.enabledLetters,
129+
required this.onLetter,
130+
required this.paranoid,
131+
this.trailing,
132+
});
133+
134+
@override
135+
Widget build(BuildContext context) {
136+
return Padding(
137+
padding: const EdgeInsets.symmetric(vertical: 3),
138+
child: Row(
139+
children: [
140+
for (final letter in letters)
141+
Expanded(
142+
child: _LetterKey(
143+
letter: letter,
144+
enabled: enabledLetters.contains(letter),
145+
paranoid: paranoid,
146+
onTap: () => onLetter(letter),
147+
),
148+
),
149+
if (trailing != null) Expanded(flex: 2, child: trailing!),
150+
],
151+
),
152+
);
153+
}
154+
}
155+
156+
class _LetterKey extends StatelessWidget {
157+
final String letter;
158+
final bool enabled;
159+
final bool paranoid;
160+
final VoidCallback onTap;
161+
162+
const _LetterKey({
163+
required this.letter,
164+
required this.enabled,
165+
required this.paranoid,
166+
required this.onTap,
167+
});
168+
169+
@override
170+
Widget build(BuildContext context) {
171+
// ExcludeSemantics: the key's letter must not reach the accessibility tree,
172+
// where a malicious accessibility service would read the seed letter by
173+
// letter as it is typed. This makes the keyboard unusable with a screen
174+
// reader by design — the recovery phrase is too sensitive to narrate.
175+
return ExcludeSemantics(
176+
child: _KeyCap(
177+
enabled: enabled,
178+
onTap: onTap,
179+
suppressAnimation: paranoid,
180+
child: BBText(
181+
letter,
182+
style: context.font.headlineLarge,
183+
color: enabled
184+
? context.appColors.onSurface
185+
: context.appColors.textMuted,
186+
),
187+
),
188+
);
189+
}
190+
}
191+
192+
class _BackspaceKey extends StatelessWidget {
193+
final bool enabled;
194+
final VoidCallback onTap;
195+
196+
const _BackspaceKey({required this.enabled, required this.onTap});
197+
198+
@override
199+
Widget build(BuildContext context) {
200+
return _KeyCap(
201+
enabled: enabled,
202+
onTap: onTap,
203+
child: Icon(
204+
Icons.backspace_outlined,
205+
size: 20,
206+
color: enabled
207+
? context.appColors.onSurface
208+
: context.appColors.textMuted,
209+
),
210+
);
211+
}
212+
}
213+
214+
/// Toggles the paranoid, randomised-layout mode. Accent-coloured while active.
215+
class _ShuffleKey extends StatelessWidget {
216+
final bool active;
217+
final String hint;
218+
final VoidCallback onTap;
219+
220+
const _ShuffleKey({
221+
required this.active,
222+
required this.hint,
223+
required this.onTap,
224+
});
225+
226+
@override
227+
Widget build(BuildContext context) {
228+
return Tooltip(
229+
message: hint,
230+
child: _KeyCap(
231+
key: const Key('mnemonicParanoidToggle'),
232+
enabled: true,
233+
onTap: onTap,
234+
child: Icon(
235+
Icons.shuffle,
236+
size: 20,
237+
color: active
238+
? context.appColors.primary
239+
: context.appColors.onSurface,
240+
),
241+
),
242+
);
243+
}
244+
}
245+
246+
/// The shared key shell: sizing, colour, and tap surface. A disabled key has
247+
/// no tap handler at all, so it cannot fire even through automation.
248+
class _KeyCap extends StatelessWidget {
249+
final bool enabled;
250+
final VoidCallback onTap;
251+
final Widget child;
252+
253+
/// When true, the key changes appearance as an instant cut with no ink
254+
/// splash. Used while the layout is reshuffling on every tap: an animated
255+
/// colour fade or a splash that outlives the reshuffle would mark, for a
256+
/// frame, which slot was just pressed — letting an observer follow a letter
257+
/// across the shuffle and defeating it.
258+
final bool suppressAnimation;
259+
260+
const _KeyCap({
261+
super.key,
262+
required this.enabled,
263+
required this.onTap,
264+
required this.child,
265+
this.suppressAnimation = false,
266+
});
267+
268+
@override
269+
Widget build(BuildContext context) {
270+
return Padding(
271+
padding: const EdgeInsets.symmetric(horizontal: 2),
272+
child: Material(
273+
// Zero duration: even in basic mode an enable/disable colour tween is
274+
// an extra frame of state history for a camera; there is no reason to
275+
// animate a key cap.
276+
animationDuration: Duration.zero,
277+
color: enabled
278+
? context.appColors.surface
279+
: context.appColors.surfaceContainerHighest,
280+
borderRadius: BorderRadius.circular(6),
281+
child: InkWell(
282+
// A key must never take focus from the word field being typed into.
283+
canRequestFocus: false,
284+
borderRadius: BorderRadius.circular(6),
285+
splashFactory: suppressAnimation ? NoSplash.splashFactory : null,
286+
highlightColor: suppressAnimation ? Colors.transparent : null,
287+
onTap: enabled ? onTap : null,
288+
child: Container(
289+
height: 44,
290+
alignment: Alignment.center,
291+
child: child,
292+
),
293+
),
294+
),
295+
);
296+
}
297+
}

0 commit comments

Comments
 (0)