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 { ...@@ -512,12 +512,7 @@ class EditableText extends StatefulWidget {
this.scrollController, this.scrollController,
this.scrollPhysics, this.scrollPhysics,
this.autocorrectionTextRectColor, this.autocorrectionTextRectColor,
this.toolbarOptions = const ToolbarOptions( ToolbarOptions? toolbarOptions,
copy: true,
cut: true,
paste: true,
selectAll: true,
),
this.autofillHints = const <String>[], this.autofillHints = const <String>[],
this.autofillClient, this.autofillClient,
this.clipBehavior = Clip.hardEdge, this.clipBehavior = Clip.hardEdge,
...@@ -560,8 +555,32 @@ class EditableText extends StatefulWidget { ...@@ -560,8 +555,32 @@ class EditableText extends StatefulWidget {
assert(rendererIgnoresPointer != null), assert(rendererIgnoresPointer != null),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(toolbarOptions != null), toolbarOptions = toolbarOptions ?? (obscureText ?
assert(clipBehavior != null), (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), assert(enableIMEPersonalizedLearning != null),
_strutStyle = strutStyle, _strutStyle = strutStyle,
keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines), keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines),
...@@ -593,7 +612,9 @@ class EditableText extends StatefulWidget { ...@@ -593,7 +612,9 @@ class EditableText extends StatefulWidget {
/// Whether to hide the text being edited (e.g., for passwords). /// 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 /// 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. /// Defaults to false. Cannot be null.
/// {@endtemplate} /// {@endtemplate}
...@@ -629,8 +650,10 @@ class EditableText extends StatefulWidget { ...@@ -629,8 +650,10 @@ class EditableText extends StatefulWidget {
/// Configuration of toolbar options. /// Configuration of toolbar options.
/// ///
/// By default, all options are enabled. If [readOnly] is true, /// By default, all options are enabled. If [readOnly] is true, paste and cut
/// paste and cut will be disabled regardless. /// 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; final ToolbarOptions toolbarOptions;
/// Whether to show selection handles. /// Whether to show selection handles.
...@@ -1573,16 +1596,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1573,16 +1596,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
@override @override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@override @override
bool get copyEnabled => widget.toolbarOptions.copy; bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
@override @override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override @override
bool get selectAllEnabled => widget.toolbarOptions.selectAll; bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
void _onChangedClipboardStatus() { void _onChangedClipboardStatus() {
setState(() { setState(() {
...@@ -1602,11 +1625,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1602,11 +1625,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
void copySelection(SelectionChangedCause cause) { void copySelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection; final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
assert(selection != null); assert(selection != null);
if (selection.isCollapsed) { if (selection.isCollapsed || widget.obscureText) {
return; return;
} }
final String text = textEditingValue.text;
Clipboard.setData(ClipboardData(text: selection.textInside(text))); Clipboard.setData(ClipboardData(text: selection.textInside(text)));
if (cause == SelectionChangedCause.toolbar) { if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent); bringIntoView(textEditingValue.selection.extent);
...@@ -1636,7 +1659,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1636,7 +1659,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// Cut current selection to [Clipboard]. /// Cut current selection to [Clipboard].
@override @override
void cutSelection(SelectionChangedCause cause) { void cutSelection(SelectionChangedCause cause) {
if (widget.readOnly) { if (widget.readOnly || widget.obscureText) {
return; return;
} }
final TextSelection selection = textEditingValue.selection; final TextSelection selection = textEditingValue.selection;
...@@ -1681,6 +1704,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1681,6 +1704,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// Select the entire text value. /// Select the entire text value.
@override @override
void selectAll(SelectionChangedCause cause) { 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( userUpdateTextEditingValue(
textEditingValue.copyWith( textEditingValue.copyWith(
selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length), selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length),
...@@ -3032,7 +3060,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3032,7 +3060,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionHeightStyle: widget.selectionHeightStyle, selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle, selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText, paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget.enableInteractiveSelection, enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText),
textSelectionDelegate: this, textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio, devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange, promptRectRange: _currentPromptRectRange,
......
...@@ -4970,82 +4970,6 @@ void main() { ...@@ -4970,82 +4970,6 @@ void main() {
variant: KeySimulatorTransitModeVariant.all() 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 // Regressing test for https://github.com/flutter/flutter/issues/78219
testWidgets('Paste does not crash when the section is inValid', (WidgetTester tester) async { testWidgets('Paste does not crash when the section is inValid', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
...@@ -5174,83 +5098,6 @@ void main() { ...@@ -5174,83 +5098,6 @@ void main() {
variant: KeySimulatorTransitModeVariant.all() 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 { testWidgets('Select all test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
......
...@@ -1506,7 +1506,7 @@ void main() { ...@@ -1506,7 +1506,7 @@ void main() {
expect(find.text('Cut'), findsNothing); 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( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: EditableText( home: EditableText(
...@@ -1514,6 +1514,12 @@ void main() { ...@@ -1514,6 +1514,12 @@ void main() {
controller: TextEditingController(text: 'blah blah'), controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode, focusNode: focusNode,
readOnly: true, readOnly: true,
toolbarOptions: const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
style: textStyle, style: textStyle,
cursorColor: cursorColor, cursorColor: cursorColor,
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
...@@ -1539,6 +1545,113 @@ void main() { ...@@ -1539,6 +1545,113 @@ void main() {
expect(find.text('Cut'), findsNothing); 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 { testWidgets('Handles the read-only flag correctly', (WidgetTester tester) async {
final TextEditingController controller = final TextEditingController controller =
TextEditingController(text: 'Lorem ipsum dolor sit amet'); 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