fix(app): the phrase typed back is pasted into its fields, never copied out

The fields the recovery phrase is typed back into offered the system's
Copy, Cut and Share, and Ctrl+C and Ctrl+X copied what was selected (the
short reading's L3), so the phrase could still reach clipboard histories,
cloud clipboards or another app. Their menu now offers Paste and Select
all alone, and the field's own copy and cut, overridden from above, do
nothing. The comment on the recovery codes no longer speaks of Copy.

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 18:01:03 +02:00
commit 87529cd5c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 162 additions and 37 deletions

View file

@ -368,22 +368,25 @@ class const PhraseField({
// 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,
child: _PasteOnly(
child: TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
contextMenuBuilder: _PasteOnly.menu,
minLines: minLines,
maxLines: 5,
maxLength: TextLimits.passage,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseField,
hintText: l10n.passwordPhraseFieldHint,
errorText: errorText,
),
),
),
);
@ -408,25 +411,28 @@ class const PhraseCheckFields({
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,
_PasteOnly(
child: TextField(
controller: controllers[i],
enabled: enabled,
autofocus: i == 0,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
contextMenuBuilder: _PasteOnly.menu,
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),
@ -437,6 +443,35 @@ class const PhraseCheckFields({
}
}
/// [child], a field the recovery phrase is typed into, from which nothing
/// is copied, cut or shared (the short reading's L3): its menu offers
/// Paste and Select all alone ([menu], which the field takes as its
/// `contextMenuBuilder`), and the keys that would copy or cut do nothing.
/// The phrase is typed back to show it was kept; a copy of it would reach
/// the clipboard's histories as one shown would (`KeepTextButtons`).
class const _PasteOnly({required final Widget child}) extends StatelessWidget {
/// A field's menu with Paste and Select all alone.
static Widget menu(BuildContext context, EditableTextState state) =>
AdaptiveTextSelectionToolbar.buttonItems(
anchors: state.contextMenuAnchors,
buttonItems: [
for (final item in state.contextMenuButtonItems)
if (item.type == ContextMenuButtonType.paste ||
item.type == ContextMenuButtonType.selectAll)
item,
],
);
@override
Widget build(BuildContext context) => Actions(
// The field's own copy and cut can be overridden from above.
actions: <Type, Action<Intent>>{
CopySelectionTextIntent: DoNothingAction(),
},
child: child,
);
}
/// Ten recovery codes, to keep.
class const RecoveryCodesView({required final List<String> codes, super.key})
extends StatelessWidget {
@ -459,8 +494,8 @@ class const RecoveryCodesView({required final List<String> codes, super.key})
spacing: 24,
runSpacing: 8,
children: [
// Not selectable: Copy empties the clipboard again, the
// system's own copy wouldn't.
// Not selectable: nothing secret is copied, saved or
// written down instead ([KeepTextButtons]).
for (final code in codes)
Text(
code,

View file

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -19,6 +20,7 @@ void main() {
return null;
}(),
'Clipboard.getData' => {'text': clipboard},
'Clipboard.hasStrings' => {'value': clipboard != null},
_ => null,
},
);
@ -101,4 +103,92 @@ void main() {
expect(find.byType(SelectableText), findsNothing);
expect(clipboard, isNull);
});
group('the phrase typed back is never copied, cut or shared from its '
'fields: pasted into them only (the short reading, L3)', () {
Future<void> withControl(
WidgetTester tester,
LogicalKeyboardKey key,
) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(key);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
}
/// Selects all of [field]'s text, then copies and cuts it with the keys.
Future<void> copyAndCut(WidgetTester tester, Finder field) async {
await tester.tap(field);
await tester.pump();
for (final key in [LogicalKeyboardKey.keyC, LogicalKeyboardKey.keyX]) {
await withControl(tester, LogicalKeyboardKey.keyA);
expect(
tester.widget<TextField>(field).controller!.selection.isCollapsed,
isFalse,
);
await withControl(tester, key);
}
}
/// Opens [field]'s menu, with its text selected (a right click).
Future<void> openMenuOn(WidgetTester tester, Finder field) async {
await withControl(tester, LogicalKeyboardKey.keyA);
final gesture = await tester.startGesture(
tester.getCenter(field),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await gesture.up();
await tester.pumpAndSettle();
}
void expectOnlyPasteAndSelectAll() {
for (final item in ['Copy', 'Cut', 'Share', 'Share...']) {
expect(find.text(item), findsNothing, reason: item);
}
expect(find.text('Paste'), findsOneWidget);
}
testWidgets('the whole phrase', (tester) async {
mockClipboard(tester);
clipboard = 'copied before';
final typed = TextEditingController(text: 'abandon ability able');
addTearDown(typed.dispose);
await pumpView(tester, PhraseField(controller: typed));
final field = find.byType(TextField);
await copyAndCut(tester, field);
await openMenuOn(tester, field);
expectOnlyPasteAndSelectAll();
expect(clipboard, 'copied before');
expect(typed.text, 'abandon ability able');
}, variant: TargetPlatformVariant.only(TargetPlatform.linux));
testWidgets('its words', (tester) async {
mockClipboard(tester);
clipboard = 'copied before';
final words = [
TextEditingController(text: 'abandon'),
TextEditingController(text: 'ability'),
];
addTearDown(() {
for (final word in words) {
word.dispose();
}
});
await pumpView(
tester,
PhraseCheckFields(positions: const [2, 7], controllers: words),
);
final field = find.byType(TextField).first;
await copyAndCut(tester, field);
await openMenuOn(tester, field);
expectOnlyPasteAndSelectAll();
expect(clipboard, 'copied before');
expect(words.first.text, 'abandon');
}, variant: TargetPlatformVariant.only(TargetPlatform.linux));
});
}