Skip to content

Commit 576e85c

Browse files
authored
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
1 parent 0c0087f commit 576e85c

File tree

2 files changed

+113
-27
lines changed

2 files changed

+113
-27
lines changed

packages/flutter/lib/src/services/text_input.dart

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,8 @@ class RawFloatingCursorPoint {
751751
class TextEditingValue {
752752
/// Creates information for editing a run of text.
753753
///
754-
/// The selection and composing range must be within the text.
754+
/// The selection and composing range must be within the text. This is not
755+
/// checked during construction, and must be guaranteed by the caller.
755756
///
756757
/// The [text], [selection], and [composing] arguments must not be null but
757758
/// each have default values.
@@ -763,23 +764,32 @@ class TextEditingValue {
763764
this.selection = const TextSelection.collapsed(offset: -1),
764765
this.composing = TextRange.empty,
765766
}) : assert(text != null),
767+
// The constructor does not verify that `selection` and `composing` are
768+
// valid ranges within `text`, and is unable to do so due to limitation
769+
// of const constructors. Some checks are performed by assertion in
770+
// other occasions. See `_textRangeIsValid`.
766771
assert(selection != null),
767772
assert(composing != null);
768773

769774
/// Creates an instance of this class from a JSON object.
770775
factory TextEditingValue.fromJSON(Map<String, dynamic> encoded) {
776+
final String text = encoded['text'] as String;
777+
final TextSelection selection = TextSelection(
778+
baseOffset: encoded['selectionBase'] as int? ?? -1,
779+
extentOffset: encoded['selectionExtent'] as int? ?? -1,
780+
affinity: _toTextAffinity(encoded['selectionAffinity'] as String?) ?? TextAffinity.downstream,
781+
isDirectional: encoded['selectionIsDirectional'] as bool? ?? false,
782+
);
783+
final TextRange composing = TextRange(
784+
start: encoded['composingBase'] as int? ?? -1,
785+
end: encoded['composingExtent'] as int? ?? -1,
786+
);
787+
assert(_textRangeIsValid(selection, text));
788+
assert(_textRangeIsValid(composing, text));
771789
return TextEditingValue(
772-
text: encoded['text'] as String,
773-
selection: TextSelection(
774-
baseOffset: encoded['selectionBase'] as int? ?? -1,
775-
extentOffset: encoded['selectionExtent'] as int? ?? -1,
776-
affinity: _toTextAffinity(encoded['selectionAffinity'] as String?) ?? TextAffinity.downstream,
777-
isDirectional: encoded['selectionIsDirectional'] as bool? ?? false,
778-
),
779-
composing: TextRange(
780-
start: encoded['composingBase'] as int? ?? -1,
781-
end: encoded['composingExtent'] as int? ?? -1,
782-
),
790+
text: text,
791+
selection: selection,
792+
composing: composing,
783793
);
784794
}
785795

@@ -884,21 +894,27 @@ class TextEditingValue {
884894
return originalIndex + replacedLength - removedLength;
885895
}
886896

897+
final TextSelection adjustedSelection = TextSelection(
898+
baseOffset: adjustIndex(selection.baseOffset),
899+
extentOffset: adjustIndex(selection.extentOffset),
900+
);
901+
final TextRange adjustedComposing = TextRange(
902+
start: adjustIndex(composing.start),
903+
end: adjustIndex(composing.end),
904+
);
905+
assert(_textRangeIsValid(adjustedSelection, newText));
906+
assert(_textRangeIsValid(adjustedComposing, newText));
887907
return TextEditingValue(
888908
text: newText,
889-
selection: TextSelection(
890-
baseOffset: adjustIndex(selection.baseOffset),
891-
extentOffset: adjustIndex(selection.extentOffset),
892-
),
893-
composing: TextRange(
894-
start: adjustIndex(composing.start),
895-
end: adjustIndex(composing.end),
896-
),
909+
selection: adjustedSelection,
910+
composing: adjustedComposing,
897911
);
898912
}
899913

900914
/// Returns a representation of this object as a JSON object.
901915
Map<String, dynamic> toJSON() {
916+
assert(_textRangeIsValid(selection, text));
917+
assert(_textRangeIsValid(composing, text));
902918
return <String, dynamic>{
903919
'text': text,
904920
'selectionBase': selection.baseOffset,
@@ -930,6 +946,24 @@ class TextEditingValue {
930946
selection.hashCode,
931947
composing.hashCode,
932948
);
949+
950+
// Verify that the given range is within the text.
951+
//
952+
// The verification can't be perform during the constructor of
953+
// [TextEditingValue], which are `const` and are allowed to retrieve
954+
// properties of [TextRange]s. [TextEditingValue] should perform this
955+
// wherever it is building other values (such as toJson) or is built in a
956+
// non-const way (such as fromJson).
957+
static bool _textRangeIsValid(TextRange range, String text) {
958+
if (range.start == -1 && range.end == -1) {
959+
return true;
960+
}
961+
assert(range.start >= 0 && range.start <= text.length,
962+
'Range start ${range.start} is out of text of length ${text.length}');
963+
assert(range.end >= 0 && range.end <= text.length,
964+
'Range end ${range.end} is out of text of length ${text.length}');
965+
return true;
966+
}
933967
}
934968

935969
/// Indicates what triggered the change in selected text (including changes to
@@ -1440,7 +1474,7 @@ TextInputAction _toTextInputAction(String action) {
14401474
return TextInputAction.next;
14411475
case 'TextInputAction.previous':
14421476
return TextInputAction.previous;
1443-
case 'TextInputAction.continue_action':
1477+
case 'TextInputAction.continueAction':
14441478
return TextInputAction.continueAction;
14451479
case 'TextInputAction.join':
14461480
return TextInputAction.join;
@@ -1534,7 +1568,7 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d
15341568
class TextInput {
15351569
TextInput._() {
15361570
_channel = SystemChannels.textInput;
1537-
_channel.setMethodCallHandler(_handleTextInputInvocation);
1571+
_channel.setMethodCallHandler(_loudlyHandleTextInputInvocation);
15381572
}
15391573

15401574
/// Set the [MethodChannel] used to communicate with the system's text input
@@ -1546,7 +1580,7 @@ class TextInput {
15461580
@visibleForTesting
15471581
static void setChannel(MethodChannel newChannel) {
15481582
assert(() {
1549-
_instance._channel = newChannel..setMethodCallHandler(_instance._handleTextInputInvocation);
1583+
_instance._channel = newChannel..setMethodCallHandler(_instance._loudlyHandleTextInputInvocation);
15501584
return true;
15511585
}());
15521586
}
@@ -1603,17 +1637,20 @@ class TextInput {
16031637
return connection;
16041638
}
16051639

1606-
/// This method actually notifies the embedding of the client. It is utilized
1607-
/// by [attach] and by [_handleTextInputInvocation] for the
1608-
/// `TextInputClient.requestExistingInputState` method.
1640+
// This method actually notifies the embedding of the client. It is utilized
1641+
// by [attach] and by [_handleTextInputInvocation] for the
1642+
// `TextInputClient.requestExistingInputState` method.
16091643
void _attach(TextInputConnection connection, TextInputConfiguration configuration) {
16101644
assert(connection != null);
16111645
assert(connection._client != null);
16121646
assert(configuration != null);
16131647
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
16141648
_channel.invokeMethod<void>(
16151649
'TextInput.setClient',
1616-
<dynamic>[ connection._id, configuration.toJson() ],
1650+
<Object>[
1651+
connection._id,
1652+
configuration.toJson(),
1653+
],
16171654
);
16181655
_currentConnection = connection;
16191656
_currentConfiguration = configuration;
@@ -1656,6 +1693,23 @@ class TextInput {
16561693
/// Returns true if a scribble interaction is currently happening.
16571694
bool get scribbleInProgress => _scribbleInProgress;
16581695

1696+
Future<dynamic> _loudlyHandleTextInputInvocation(MethodCall call) async {
1697+
try {
1698+
return await _handleTextInputInvocation(call);
1699+
} catch (exception, stack) {
1700+
FlutterError.reportError(FlutterErrorDetails(
1701+
exception: exception,
1702+
stack: stack,
1703+
library: 'services library',
1704+
context: ErrorDescription('during method call ${call.method}'),
1705+
informationCollector: () => <DiagnosticsNode>[
1706+
DiagnosticsProperty<MethodCall>('call', call, style: DiagnosticsTreeStyle.errorProperty),
1707+
],
1708+
));
1709+
rethrow;
1710+
}
1711+
}
1712+
16591713
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
16601714
final String method = methodCall.method;
16611715
if (method == 'TextInputClient.focusElement') {

packages/flutter/test/services/text_input_test.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import 'dart:convert' show jsonDecode;
77
import 'dart:ui';
88

9+
import 'package:flutter/foundation.dart';
910
import 'package:flutter/services.dart';
1011
import 'package:flutter_test/flutter_test.dart';
1112

@@ -210,6 +211,37 @@ void main() {
210211
),
211212
]);
212213
});
214+
215+
test('Invalid TextRange fails loudly when being converted to JSON', () async {
216+
final List<FlutterErrorDetails> record = <FlutterErrorDetails>[];
217+
FlutterError.onError = (FlutterErrorDetails details) {
218+
record.add(details);
219+
};
220+
221+
final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test3'));
222+
const TextInputConfiguration configuration = TextInputConfiguration();
223+
TextInput.attach(client, configuration);
224+
225+
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
226+
'method': 'TextInputClient.updateEditingState',
227+
'args': <dynamic>[-1, <String, dynamic>{
228+
'text': '1',
229+
'selectionBase': 2,
230+
'selectionExtent': 3,
231+
}],
232+
});
233+
234+
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
235+
'flutter/textinput',
236+
messageBytes,
237+
(ByteData? _) {},
238+
);
239+
expect(record.length, 1);
240+
// Verify the error message in parts because Web formats the message
241+
// differently from others.
242+
expect(record[0].exception.toString(), matches(RegExp(r'\brange.start >= 0 && range.start <= text.length\b')));
243+
expect(record[0].exception.toString(), matches(RegExp(r'\bRange start 2 is out of text of length 1\b')));
244+
});
213245
});
214246

215247
group('TextInputConfiguration', () {

0 commit comments

Comments
 (0)