Unverified Commit c5f108f0 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Disallow copy and cut when `obscureText` is set on `TextField` (#96233)

Before this change, it was possible to select and copy obscured text from a text field.

This PR changes things so that:

- Obscured text fields don't allow copy or cut.
- If a field is both obscured and read-only, then selection is disabled as well (if you can't modify it, and can't copy it, there's no point in selecting it).
parent 3750beac
......@@ -512,12 +512,7 @@ class EditableText extends StatefulWidget {
this.scrollController,
this.scrollPhysics,
this.autocorrectionTextRectColor,
this.toolbarOptions = const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
ToolbarOptions? toolbarOptions,
this.autofillHints = const <String>[],
this.autofillClient,
this.clipBehavior = Clip.hardEdge,
......@@ -560,8 +555,32 @@ class EditableText extends StatefulWidget {
assert(rendererIgnoresPointer != null),
assert(scrollPadding != null),
assert(dragStartBehavior != null),
assert(toolbarOptions != null),
assert(clipBehavior != null),
toolbarOptions = toolbarOptions ?? (obscureText ?
(readOnly ?
// No point in even offering "Select All" in a read-only obscured
// field.
const ToolbarOptions() :
// Writable, but obscured
const ToolbarOptions(
selectAll: true,
paste: true,
)
) :
(readOnly ?
// Read-only, not obscured
const ToolbarOptions(
selectAll: true,
copy: true,
) :
// Writable, not obscured
const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
)
)
), assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
_strutStyle = strutStyle,
keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines),
......@@ -593,7 +612,9 @@ class EditableText extends StatefulWidget {
/// Whether to hide the text being edited (e.g., for passwords).
///
/// When this is set to true, all the characters in the text field are
/// replaced by [obscuringCharacter].
/// replaced by [obscuringCharacter], and the text in the field cannot be
/// copied with copy or cut. If [readOnly] is also true, then the text cannot
/// be selected.
///
/// Defaults to false. Cannot be null.
/// {@endtemplate}
......@@ -629,8 +650,10 @@ class EditableText extends StatefulWidget {
/// Configuration of toolbar options.
///
/// By default, all options are enabled. If [readOnly] is true,
/// paste and cut will be disabled regardless.
/// By default, all options are enabled. If [readOnly] is true, paste and cut
/// will be disabled regardless. If [obscureText] is true, cut and copy will
/// be disabled regardless. If [readOnly] and [obscureText] are both true,
/// select all will also be disabled.
final ToolbarOptions toolbarOptions;
/// Whether to show selection handles.
......@@ -1573,16 +1596,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@override
bool get copyEnabled => widget.toolbarOptions.copy;
bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll;
bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
void _onChangedClipboardStatus() {
setState(() {
......@@ -1602,11 +1625,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
void copySelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
assert(selection != null);
if (selection.isCollapsed) {
if (selection.isCollapsed || widget.obscureText) {
return;
}
final String text = textEditingValue.text;
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
......@@ -1636,7 +1659,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// Cut current selection to [Clipboard].
@override
void cutSelection(SelectionChangedCause cause) {
if (widget.readOnly) {
if (widget.readOnly || widget.obscureText) {
return;
}
final TextSelection selection = textEditingValue.selection;
......@@ -1681,6 +1704,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// Select the entire text value.
@override
void selectAll(SelectionChangedCause cause) {
if (widget.readOnly && widget.obscureText) {
// If we can't modify it, and we can't copy it, there's no point in
// selecting it.
return;
}
userUpdateTextEditingValue(
textEditingValue.copyWith(
selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length),
......@@ -3032,7 +3060,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget.enableInteractiveSelection,
enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText),
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange,
......
......@@ -4970,82 +4970,6 @@ void main() {
variant: KeySimulatorTransitModeVariant.all()
);
testWidgets('Copy paste obscured text test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController();
final TextField textField =
TextField(
controller: controller,
obscureText: true,
);
String clipboardContent = '';
tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.setData')
// ignore: avoid_dynamic_calls
clipboardContent = methodCall.arguments['text'] as String;
else if (methodCall.method == 'Clipboard.getData')
return <String, dynamic>{'text': clipboardContent};
return null;
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: RawKeyboardListener(
focusNode: focusNode,
child: textField,
),
),
),
);
focusNode.requestFocus();
await tester.pump();
const String testValue = 'a big house jumped over a mouse';
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Select the first 5 characters
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
for (int i = 0; i < 5; i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
// Copy them
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight);
await tester.sendKeyEvent(LogicalKeyboardKey.keyC);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
expect(clipboardContent, 'a big');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
// Paste them
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 200));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
const String expected = 'a biga big house jumped over a mouse';
expect(find.text(expected), findsOneWidget, reason: 'Because text contains ${controller.text}');
},
skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events.
variant: KeySimulatorTransitModeVariant.all()
);
// Regressing test for https://github.com/flutter/flutter/issues/78219
testWidgets('Paste does not crash when the section is inValid', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
......@@ -5174,83 +5098,6 @@ void main() {
variant: KeySimulatorTransitModeVariant.all()
);
testWidgets('Cut obscured text test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController();
final TextField textField = TextField(
controller: controller,
obscureText: true,
);
String clipboardContent = '';
tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.setData')
// ignore: avoid_dynamic_calls
clipboardContent = methodCall.arguments['text'] as String;
else if (methodCall.method == 'Clipboard.getData')
return <String, dynamic>{'text': clipboardContent};
return null;
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: RawKeyboardListener(
focusNode: focusNode,
child: textField,
),
),
),
);
focusNode.requestFocus();
await tester.pump();
const String testValue = 'a big house jumped over a mouse';
await tester.enterText(find.byType(TextField), testValue);
await tester.idle();
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// Select the first 5 characters
for (int i = 0; i < 5; i += 1) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
}
// Cut them
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight);
await tester.sendKeyEvent(LogicalKeyboardKey.keyX);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
expect(clipboardContent, 'a big');
for (int i = 0; i < 5; i += 1) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
}
// Paste them
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 200));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
await tester.pumpAndSettle();
const String expected = ' housa bige jumped over a mouse';
expect(find.text(expected), findsOneWidget);
},
skip: areKeyEventsHandledByPlatform, // [intended] only applies to platforms where we handle key events.
variant: KeySimulatorTransitModeVariant.all()
);
testWidgets('Select all test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController();
......
......@@ -1506,7 +1506,7 @@ void main() {
expect(find.text('Cut'), findsNothing);
});
testWidgets('cut and paste are disabled in read only mode even if explicit set', (WidgetTester tester) async {
testWidgets('cut and paste are disabled in read only mode even if explicitly set', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: EditableText(
......@@ -1514,6 +1514,12 @@ void main() {
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
readOnly: true,
toolbarOptions: const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
......@@ -1539,6 +1545,113 @@ void main() {
expect(find.text('Cut'), findsNothing);
});
testWidgets('cut and copy are disabled in obscured mode even if explicitly set', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
obscureText: true,
toolbarOptions: const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
await tester.tap(find.byType(EditableText));
await tester.pump();
// Select something, but not the whole thing.
state.renderEditable.selectWord(cause: SelectionChangedCause.tap);
await tester.pump();
expect(state.selectAllEnabled, isTrue);
expect(state.pasteEnabled, isTrue);
expect(state.cutEnabled, isFalse);
expect(state.copyEnabled, isFalse);
// On web, we don't let Flutter show the toolbar.
expect(state.showToolbar(), kIsWeb ? isFalse : isTrue);
await tester.pump();
expect(find.text('Select all'), kIsWeb ? findsNothing : findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget);
expect(find.text('Cut'), findsNothing);
});
testWidgets('cut and copy do nothing in obscured mode even if explicitly called', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
obscureText: true,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(state.selectAllEnabled, isTrue);
expect(state.pasteEnabled, isTrue);
expect(state.cutEnabled, isFalse);
expect(state.copyEnabled, isFalse);
// Select all.
state.selectAll(SelectionChangedCause.toolbar);
await tester.pump();
await Clipboard.setData(const ClipboardData(text: ''));
state.cutSelection(SelectionChangedCause.toolbar);
ClipboardData? data = await Clipboard.getData('text/plain');
expect(data, isNotNull);
expect(data!.text, isEmpty);
state.selectAll(SelectionChangedCause.toolbar);
await tester.pump();
await Clipboard.setData(const ClipboardData(text: ''));
state.copySelection(SelectionChangedCause.toolbar);
data = await Clipboard.getData('text/plain');
expect(data, isNotNull);
expect(data!.text, isEmpty);
});
testWidgets('select all does nothing if obscured and read-only, even if explicitly called', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
obscureText: true,
readOnly: true,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
// Select all.
state.selectAll(SelectionChangedCause.toolbar);
expect(state.selectAllEnabled, isFalse);
expect(state.textEditingValue.selection.isCollapsed, isTrue);
});
testWidgets('Handles the read-only flag correctly', (WidgetTester tester) async {
final TextEditingController controller =
TextEditingController(text: 'Lorem ipsum dolor sit amet');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment