fix(app): the phrase typed back is kept out of screenshots too

The reading of the full reading's fixes found that the recovery phrase
typed back (recovering, resetting an authenticator, finishing a reset
that keeps the keys) showed all 24 words in a plain field, in Android's
recent apps, screenshots and iOS's app switcher, while people switch
apps to read it; and the three words typed back at sign-up alike.
PhraseField and PhraseCheckFields now show inside a SecretScreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 16:17:26 +02:00
commit fac9a000a1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 63 additions and 44 deletions

View file

@ -364,22 +364,27 @@ class const PhraseField({
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
minLines: minLines,
maxLines: 5,
maxLength: TextLimits.passage,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseField,
hintText: l10n.passwordPhraseFieldHint,
errorText: errorText,
// Typed back, the whole phrase shows: kept out of screenshots and recent
// apps while it does, as when it was first shown (the reading of the
// fixes).
return SecretScreen(
child: TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
minLines: minLines,
maxLines: 5,
maxLength: TextLimits.passage,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseField,
hintText: l10n.passwordPhraseFieldHint,
errorText: errorText,
),
),
);
}
@ -397,34 +402,37 @@ class const PhraseCheckFields({
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < positions.length; i++) ...[
TextField(
controller: controllers[i],
enabled: enabled,
autofocus: i == 0,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
textInputAction: i == positions.length - 1
? TextInputAction.done
: TextInputAction.next,
onSubmitted: i == positions.length - 1
? (_) => onDone?.call()
: null,
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseWord(positions[i] + 1),
errorText: i == positions.length - 1 ? errorText : null,
// Words of the phrase: kept out of screenshots and recent apps too.
return SecretScreen(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < positions.length; i++) ...[
TextField(
controller: controllers[i],
enabled: enabled,
autofocus: i == 0,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
textInputAction: i == positions.length - 1
? TextInputAction.done
: TextInputAction.next,
onSubmitted: i == positions.length - 1
? (_) => onDone?.call()
: null,
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseWord(positions[i] + 1),
errorText: i == positions.length - 1 ? errorText : null,
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 8),
],
],
],
),
);
}
}

View file

@ -46,11 +46,22 @@ void main() {
expect(find.byKey(SecretScreen.coverKey), findsNothing);
});
testWidgets('the phrase, the recovery codes and an authenticator are each '
'shown as a secret', (tester) async {
testWidgets('the phrase, shown or typed back, the recovery codes and an '
'authenticator are each shown as a secret (typed back: the reading of '
'the fixes)', (tester) async {
final code = TextEditingController();
addTearDown(code.dispose);
final typed = TextEditingController();
addTearDown(typed.dispose);
final words = [TextEditingController(), TextEditingController()];
addTearDown(() {
for (final word in words) {
word.dispose();
}
});
for (final view in [
PhraseField(controller: typed),
PhraseCheckFields(positions: const [2, 7], controllers: words),
const RecoveryPhraseView(
phrase:
'abandon abandon abandon abandon abandon abandon abandon abandon '