Unverified Commit 576e85ca authored by Tong Mu's avatar Tong Mu Committed by GitHub

TextInput: Verify TextRange and make method call fail loudly (#104711)

* Fix

* Tests

* Format

* Empty line

* Fix range test

* Fix

* Comments

* trailing spaces

* Fix tests

* Comments
parent 0c0087f8
...@@ -751,7 +751,8 @@ class RawFloatingCursorPoint { ...@@ -751,7 +751,8 @@ class RawFloatingCursorPoint {
class TextEditingValue { class TextEditingValue {
/// Creates information for editing a run of text. /// Creates information for editing a run of text.
/// ///
/// The selection and composing range must be within the text. /// The selection and composing range must be within the text. This is not
/// checked during construction, and must be guaranteed by the caller.
/// ///
/// The [text], [selection], and [composing] arguments must not be null but /// The [text], [selection], and [composing] arguments must not be null but
/// each have default values. /// each have default values.
...@@ -763,23 +764,32 @@ class TextEditingValue { ...@@ -763,23 +764,32 @@ class TextEditingValue {
this.selection = const TextSelection.collapsed(offset: -1), this.selection = const TextSelection.collapsed(offset: -1),
this.composing = TextRange.empty, this.composing = TextRange.empty,
}) : assert(text != null), }) : assert(text != null),
// The constructor does not verify that `selection` and `composing` are
// valid ranges within `text`, and is unable to do so due to limitation
// of const constructors. Some checks are performed by assertion in
// other occasions. See `_textRangeIsValid`.
assert(selection != null), assert(selection != null),
assert(composing != null); assert(composing != null);
/// Creates an instance of this class from a JSON object. /// Creates an instance of this class from a JSON object.
factory TextEditingValue.fromJSON(Map<String, dynamic> encoded) { factory TextEditingValue.fromJSON(Map<String, dynamic> encoded) {
return TextEditingValue( final String text = encoded['text'] as String;
text: encoded['text'] as String, final TextSelection selection = TextSelection(
selection: TextSelection(
baseOffset: encoded['selectionBase'] as int? ?? -1, baseOffset: encoded['selectionBase'] as int? ?? -1,
extentOffset: encoded['selectionExtent'] as int? ?? -1, extentOffset: encoded['selectionExtent'] as int? ?? -1,
affinity: _toTextAffinity(encoded['selectionAffinity'] as String?) ?? TextAffinity.downstream, affinity: _toTextAffinity(encoded['selectionAffinity'] as String?) ?? TextAffinity.downstream,
isDirectional: encoded['selectionIsDirectional'] as bool? ?? false, isDirectional: encoded['selectionIsDirectional'] as bool? ?? false,
), );
composing: TextRange( final TextRange composing = TextRange(
start: encoded['composingBase'] as int? ?? -1, start: encoded['composingBase'] as int? ?? -1,
end: encoded['composingExtent'] as int? ?? -1, end: encoded['composingExtent'] as int? ?? -1,
), );
assert(_textRangeIsValid(selection, text));
assert(_textRangeIsValid(composing, text));
return TextEditingValue(
text: text,
selection: selection,
composing: composing,
); );
} }
...@@ -884,21 +894,27 @@ class TextEditingValue { ...@@ -884,21 +894,27 @@ class TextEditingValue {
return originalIndex + replacedLength - removedLength; return originalIndex + replacedLength - removedLength;
} }
return TextEditingValue( final TextSelection adjustedSelection = TextSelection(
text: newText,
selection: TextSelection(
baseOffset: adjustIndex(selection.baseOffset), baseOffset: adjustIndex(selection.baseOffset),
extentOffset: adjustIndex(selection.extentOffset), extentOffset: adjustIndex(selection.extentOffset),
), );
composing: TextRange( final TextRange adjustedComposing = TextRange(
start: adjustIndex(composing.start), start: adjustIndex(composing.start),
end: adjustIndex(composing.end), end: adjustIndex(composing.end),
), );
assert(_textRangeIsValid(adjustedSelection, newText));
assert(_textRangeIsValid(adjustedComposing, newText));
return TextEditingValue(
text: newText,
selection: adjustedSelection,
composing: adjustedComposing,
); );
} }
/// Returns a representation of this object as a JSON object. /// Returns a representation of this object as a JSON object.
Map<String, dynamic> toJSON() { Map<String, dynamic> toJSON() {
assert(_textRangeIsValid(selection, text));
assert(_textRangeIsValid(composing, text));
return <String, dynamic>{ return <String, dynamic>{
'text': text, 'text': text,
'selectionBase': selection.baseOffset, 'selectionBase': selection.baseOffset,
...@@ -930,6 +946,24 @@ class TextEditingValue { ...@@ -930,6 +946,24 @@ class TextEditingValue {
selection.hashCode, selection.hashCode,
composing.hashCode, composing.hashCode,
); );
// Verify that the given range is within the text.
//
// The verification can't be perform during the constructor of
// [TextEditingValue], which are `const` and are allowed to retrieve
// properties of [TextRange]s. [TextEditingValue] should perform this
// wherever it is building other values (such as toJson) or is built in a
// non-const way (such as fromJson).
static bool _textRangeIsValid(TextRange range, String text) {
if (range.start == -1 && range.end == -1) {
return true;
}
assert(range.start >= 0 && range.start <= text.length,
'Range start ${range.start} is out of text of length ${text.length}');
assert(range.end >= 0 && range.end <= text.length,
'Range end ${range.end} is out of text of length ${text.length}');
return true;
}
} }
/// Indicates what triggered the change in selected text (including changes to /// Indicates what triggered the change in selected text (including changes to
...@@ -1440,7 +1474,7 @@ TextInputAction _toTextInputAction(String action) { ...@@ -1440,7 +1474,7 @@ TextInputAction _toTextInputAction(String action) {
return TextInputAction.next; return TextInputAction.next;
case 'TextInputAction.previous': case 'TextInputAction.previous':
return TextInputAction.previous; return TextInputAction.previous;
case 'TextInputAction.continue_action': case 'TextInputAction.continueAction':
return TextInputAction.continueAction; return TextInputAction.continueAction;
case 'TextInputAction.join': case 'TextInputAction.join':
return TextInputAction.join; return TextInputAction.join;
...@@ -1534,7 +1568,7 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d ...@@ -1534,7 +1568,7 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d
class TextInput { class TextInput {
TextInput._() { TextInput._() {
_channel = SystemChannels.textInput; _channel = SystemChannels.textInput;
_channel.setMethodCallHandler(_handleTextInputInvocation); _channel.setMethodCallHandler(_loudlyHandleTextInputInvocation);
} }
/// Set the [MethodChannel] used to communicate with the system's text input /// Set the [MethodChannel] used to communicate with the system's text input
...@@ -1546,7 +1580,7 @@ class TextInput { ...@@ -1546,7 +1580,7 @@ class TextInput {
@visibleForTesting @visibleForTesting
static void setChannel(MethodChannel newChannel) { static void setChannel(MethodChannel newChannel) {
assert(() { assert(() {
_instance._channel = newChannel..setMethodCallHandler(_instance._handleTextInputInvocation); _instance._channel = newChannel..setMethodCallHandler(_instance._loudlyHandleTextInputInvocation);
return true; return true;
}()); }());
} }
...@@ -1603,9 +1637,9 @@ class TextInput { ...@@ -1603,9 +1637,9 @@ class TextInput {
return connection; return connection;
} }
/// This method actually notifies the embedding of the client. It is utilized // This method actually notifies the embedding of the client. It is utilized
/// by [attach] and by [_handleTextInputInvocation] for the // by [attach] and by [_handleTextInputInvocation] for the
/// `TextInputClient.requestExistingInputState` method. // `TextInputClient.requestExistingInputState` method.
void _attach(TextInputConnection connection, TextInputConfiguration configuration) { void _attach(TextInputConnection connection, TextInputConfiguration configuration) {
assert(connection != null); assert(connection != null);
assert(connection._client != null); assert(connection._client != null);
...@@ -1613,7 +1647,10 @@ class TextInput { ...@@ -1613,7 +1647,10 @@ class TextInput {
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction)); assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
_channel.invokeMethod<void>( _channel.invokeMethod<void>(
'TextInput.setClient', 'TextInput.setClient',
<dynamic>[ connection._id, configuration.toJson() ], <Object>[
connection._id,
configuration.toJson(),
],
); );
_currentConnection = connection; _currentConnection = connection;
_currentConfiguration = configuration; _currentConfiguration = configuration;
...@@ -1656,6 +1693,23 @@ class TextInput { ...@@ -1656,6 +1693,23 @@ class TextInput {
/// Returns true if a scribble interaction is currently happening. /// Returns true if a scribble interaction is currently happening.
bool get scribbleInProgress => _scribbleInProgress; bool get scribbleInProgress => _scribbleInProgress;
Future<dynamic> _loudlyHandleTextInputInvocation(MethodCall call) async {
try {
return await _handleTextInputInvocation(call);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('during method call ${call.method}'),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<MethodCall>('call', call, style: DiagnosticsTreeStyle.errorProperty),
],
));
rethrow;
}
}
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async { Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
final String method = methodCall.method; final String method = methodCall.method;
if (method == 'TextInputClient.focusElement') { if (method == 'TextInputClient.focusElement') {
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
import 'dart:convert' show jsonDecode; import 'dart:convert' show jsonDecode;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -210,6 +211,37 @@ void main() { ...@@ -210,6 +211,37 @@ void main() {
), ),
]); ]);
}); });
test('Invalid TextRange fails loudly when being converted to JSON', () async {
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
FlutterError.onError = (FlutterErrorDetails details) {
record.add(details);
};
final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test3'));
const TextInputConfiguration configuration = TextInputConfiguration();
TextInput.attach(client, configuration);
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
'method': 'TextInputClient.updateEditingState',
'args': <dynamic>[-1, <String, dynamic>{
'text': '1',
'selectionBase': 2,
'selectionExtent': 3,
}],
});
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'flutter/textinput',
messageBytes,
(ByteData? _) {},
);
expect(record.length, 1);
// Verify the error message in parts because Web formats the message
// differently from others.
expect(record[0].exception.toString(), matches(RegExp(r'\brange.start >= 0 && range.start <= text.length\b')));
expect(record[0].exception.toString(), matches(RegExp(r'\bRange start 2 is out of text of length 1\b')));
});
}); });
group('TextInputConfiguration', () { group('TextInputConfiguration', () {
......
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