Unverified Commit 7957c569 authored by Dan Field's avatar Dan Field Committed by GitHub

Respond to TextInputClient.reattach messages. (#43959)

parent b94c1a41
...@@ -159,6 +159,13 @@ class SystemChannels { ...@@ -159,6 +159,13 @@ class SystemChannels {
/// second argument is a [String] consisting of the stringification of one /// second argument is a [String] consisting of the stringification of one
/// of the values of the [TextInputAction] enum. /// of the values of the [TextInputAction] enum.
/// ///
/// * `TextInputClient.requestExistingInputState`: The embedding may have
/// lost its internal state about the current editing client, if there is
/// one. The framework should call `TextInput.setClient` and
/// `TextInput.setEditingState` again with its most recent information. If
/// there is no existing state on the framework side, the call should
/// fizzle.
///
/// * `TextInputClient.onConnectionClosed`: The text input connection closed /// * `TextInputClient.onConnectionClosed`: The text input connection closed
/// on the platform side. For example the application is moved to /// on the platform side. For example the application is moved to
/// background or used closed the virtual keyboard. This method informs /// background or used closed the virtual keyboard. This method informs
......
...@@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart'; ...@@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'package:vector_math/vector_math_64.dart' show Matrix4;
import 'message_codec.dart'; import 'message_codec.dart';
import 'platform_channel.dart';
import 'system_channels.dart'; import 'system_channels.dart';
import 'system_chrome.dart'; import 'system_chrome.dart';
import 'text_editing.dart'; import 'text_editing.dart';
...@@ -673,24 +674,34 @@ class TextInputConnection { ...@@ -673,24 +674,34 @@ class TextInputConnection {
static int _nextId = 1; static int _nextId = 1;
final int _id; final int _id;
/// Resets the internal ID counter for testing purposes.
///
/// This call has no effect when asserts are disabled. Calling it from
/// application code will likely break text input for the application.
@visibleForTesting
static void debugResetId({int to = 1}) {
assert(to != null);
assert(() {
_nextId = to;
return true;
}());
}
final TextInputClient _client; final TextInputClient _client;
/// Whether this connection is currently interacting with the text input control. /// Whether this connection is currently interacting with the text input control.
bool get attached => _clientHandler._currentConnection == this; bool get attached => TextInput._instance._currentConnection == this;
/// Requests that the text input control become visible. /// Requests that the text input control become visible.
void show() { void show() {
assert(attached); assert(attached);
SystemChannels.textInput.invokeMethod<void>('TextInput.show'); TextInput._instance._show();
} }
/// Requests that the text input control change its internal state to match the given state. /// Requests that the text input control change its internal state to match the given state.
void setEditingState(TextEditingValue value) { void setEditingState(TextEditingValue value) {
assert(attached); assert(attached);
SystemChannels.textInput.invokeMethod<void>( TextInput._instance._setEditingState(value);
'TextInput.setEditingState',
value.toJSON(),
);
} }
/// Send the size and transform of the editable text to engine. /// Send the size and transform of the editable text to engine.
...@@ -706,8 +717,7 @@ class TextInputConnection { ...@@ -706,8 +717,7 @@ class TextInputConnection {
if (editableBoxSize != _cachedSize || transform != _cachedTransform) { if (editableBoxSize != _cachedSize || transform != _cachedTransform) {
_cachedSize = editableBoxSize; _cachedSize = editableBoxSize;
_cachedTransform = transform; _cachedTransform = transform;
SystemChannels.textInput.invokeMethod<void>( TextInput._instance._setEditableSizeAndTransform(
'TextInput.setEditableSizeAndTransform',
<String, dynamic>{ <String, dynamic>{
'width': editableBoxSize.width, 'width': editableBoxSize.width,
'height': editableBoxSize.height, 'height': editableBoxSize.height,
...@@ -731,8 +741,7 @@ class TextInputConnection { ...@@ -731,8 +741,7 @@ class TextInputConnection {
}) { }) {
assert(attached); assert(attached);
SystemChannels.textInput.invokeMethod<void>( TextInput._instance._setStyle(
'TextInput.setStyle',
<String, dynamic>{ <String, dynamic>{
'fontFamily': fontFamily, 'fontFamily': fontFamily,
'fontSize': fontSize, 'fontSize': fontSize,
...@@ -749,10 +758,7 @@ class TextInputConnection { ...@@ -749,10 +758,7 @@ class TextInputConnection {
/// other client attaches to it within this animation frame. /// other client attaches to it within this animation frame.
void close() { void close() {
if (attached) { if (attached) {
SystemChannels.textInput.invokeMethod<void>('TextInput.clearClient'); TextInput._instance._clearClient();
_clientHandler
.._currentConnection = null
.._scheduleHide();
} }
assert(!attached); assert(!attached);
} }
...@@ -761,7 +767,7 @@ class TextInputConnection { ...@@ -761,7 +767,7 @@ class TextInputConnection {
/// ///
/// [TextInputConnection] should clean current client connection. /// [TextInputConnection] should clean current client connection.
void connectionClosedReceived() { void connectionClosedReceived() {
_clientHandler._currentConnection = null; TextInput._instance._currentConnection = null;
assert(!attached); assert(!attached);
} }
} }
...@@ -818,63 +824,28 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d ...@@ -818,63 +824,28 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d
return RawFloatingCursorPoint(offset: offset, state: state); return RawFloatingCursorPoint(offset: offset, state: state);
} }
class _TextInputClientHandler { /// An interface to the system's text input control.
_TextInputClientHandler() { class TextInput {
SystemChannels.textInput.setMethodCallHandler(_handleTextInputInvocation); TextInput._() {
} _channel = SystemChannels.textInput;
_channel.setMethodCallHandler(_handleTextInputInvocation);
TextInputConnection _currentConnection;
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
if (_currentConnection == null)
return;
final String method = methodCall.method;
final List<dynamic> args = methodCall.arguments;
final int client = args[0];
// The incoming message was for a different client.
if (client != _currentConnection._id)
return;
switch (method) {
case 'TextInputClient.updateEditingState':
_currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
break;
case 'TextInputClient.performAction':
_currentConnection._client.performAction(_toTextInputAction(args[1]));
break;
case 'TextInputClient.updateFloatingCursor':
_currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
break;
case 'TextInputClient.onConnectionClosed':
_currentConnection._client.connectionClosed();
break;
default:
throw MissingPluginException();
}
} }
bool _hidePending = false; /// Set the [MethodChannel] used to communicate with the system's text input
/// control.
void _scheduleHide() { ///
if (_hidePending) /// This is only meant for testing within the Flutter SDK. Changing this
return; /// will break the ability to input text. This has no effect if asserts are
_hidePending = true; /// disabled.
@visibleForTesting
// Schedule a deferred task that hides the text input. If someone else static void setChannel(MethodChannel newChannel) {
// shows the keyboard during this update cycle, then the task will do assert(() {
// nothing. _instance._channel = newChannel..setMethodCallHandler(_instance._handleTextInputInvocation);
scheduleMicrotask(() { return true;
_hidePending = false; }());
if (_currentConnection == null)
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
});
} }
}
final _TextInputClientHandler _clientHandler = _TextInputClientHandler(); static final TextInput _instance = TextInput._();
/// An interface to the system's text input control.
class TextInput {
TextInput._();
static const List<TextInputAction> _androidSupportedInputActions = <TextInputAction>[ static const List<TextInputAction> _androidSupportedInputActions = <TextInputAction>[
TextInputAction.none, TextInputAction.none,
...@@ -915,14 +886,25 @@ class TextInput { ...@@ -915,14 +886,25 @@ class TextInput {
static TextInputConnection attach(TextInputClient client, TextInputConfiguration configuration) { static TextInputConnection attach(TextInputClient client, TextInputConfiguration configuration) {
assert(client != null); assert(client != null);
assert(configuration != null); assert(configuration != null);
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
final TextInputConnection connection = TextInputConnection._(client); final TextInputConnection connection = TextInputConnection._(client);
_clientHandler._currentConnection = connection; _instance._attach(connection, configuration);
SystemChannels.textInput.invokeMethod<void>( return connection;
}
/// This method actually notifies the embedding of the client. It is utilized
/// by [attach] and by [_handleTextInputInvocation] for the
/// `TextInputClient.requestExistingInputState` method.
void _attach(TextInputConnection connection, TextInputConfiguration configuration) {
assert(connection != null);
assert(connection._client != null);
assert(configuration != null);
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
_channel.invokeMethod<void>(
'TextInput.setClient', 'TextInput.setClient',
<dynamic>[ connection._id, configuration.toJson() ], <dynamic>[ connection._id, configuration.toJson() ],
); );
return connection; _currentConnection = connection;
_currentConfiguration = configuration;
} }
static bool _debugEnsureInputActionWorksOnPlatform(TextInputAction inputAction) { static bool _debugEnsureInputActionWorksOnPlatform(TextInputAction inputAction) {
...@@ -946,4 +928,100 @@ class TextInput { ...@@ -946,4 +928,100 @@ class TextInput {
}()); }());
return true; return true;
} }
MethodChannel _channel;
TextInputConnection _currentConnection;
TextInputConfiguration _currentConfiguration;
TextEditingValue _currentTextEditingValue;
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
if (_currentConnection == null)
return;
final String method = methodCall.method;
// The requestExistingInputState request needs to be handled regardless of
// the client ID, as long as we have a _currentConnection.
if (method == 'TextInputClient.requestExistingInputState') {
assert(_currentConnection._client != null);
_attach(_currentConnection, _currentConfiguration);
// This will be null if we've never had a call to [_setEditingState].
if (_currentTextEditingValue != null) {
_setEditingState(_currentTextEditingValue);
}
return;
}
final List<dynamic> args = methodCall.arguments;
final int client = args[0];
// The incoming message was for a different client.
if (client != _currentConnection._id)
return;
switch (method) {
case 'TextInputClient.updateEditingState':
_currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
break;
case 'TextInputClient.performAction':
_currentConnection._client.performAction(_toTextInputAction(args[1]));
break;
case 'TextInputClient.updateFloatingCursor':
_currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
break;
case 'TextInputClient.onConnectionClosed':
_currentConnection._client.connectionClosed();
break;
default:
throw MissingPluginException();
}
}
bool _hidePending = false;
void _scheduleHide() {
if (_hidePending)
return;
_hidePending = true;
// Schedule a deferred task that hides the text input. If someone else
// shows the keyboard during this update cycle, then the task will do
// nothing.
scheduleMicrotask(() {
_hidePending = false;
if (_currentConnection == null)
_channel.invokeMethod<void>('TextInput.hide');
});
}
void _clearClient() {
_channel.invokeMethod<void>('TextInput.clearClient');
_currentConnection = null;
_scheduleHide();
}
void _setEditingState(TextEditingValue value) {
assert(value != null);
_channel.invokeMethod<void>(
'TextInput.setEditingState',
value.toJSON(),
);
_currentTextEditingValue = value;
}
void _show() {
_channel.invokeMethod<void>('TextInput.show');
}
void _setEditableSizeAndTransform(Map<String, dynamic> args) {
_channel.invokeMethod<void>(
'TextInput.setEditableSizeAndTransform',
args,
);
}
void _setStyle(Map<String, dynamic> args) {
_channel.invokeMethod<void>(
'TextInput.setStyle',
args,
);
}
} }
...@@ -1395,19 +1395,20 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1395,19 +1395,20 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (!_hasInputConnection) { if (!_hasInputConnection) {
final TextEditingValue localValue = _value; final TextEditingValue localValue = _value;
_lastKnownRemoteTextEditingValue = localValue; _lastKnownRemoteTextEditingValue = localValue;
_textInputConnection = TextInput.attach(this, _textInputConnection = TextInput.attach(
TextInputConfiguration( this,
inputType: widget.keyboardType, TextInputConfiguration(
obscureText: widget.obscureText, inputType: widget.keyboardType,
autocorrect: widget.autocorrect, obscureText: widget.obscureText,
enableSuggestions: widget.enableSuggestions, autocorrect: widget.autocorrect,
inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline enableSuggestions: widget.enableSuggestions,
? TextInputAction.newline inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline
: TextInputAction.done ? TextInputAction.newline
), : TextInputAction.done
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
), ),
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
),
); );
_textInputConnection.show(); _textInputConnection.show();
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert' show utf8;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding; import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding;
import '../flutter_test_alternative.dart'; import '../flutter_test_alternative.dart';
...@@ -9,6 +11,66 @@ import '../flutter_test_alternative.dart'; ...@@ -9,6 +11,66 @@ import '../flutter_test_alternative.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('TextInput message channels', () {
FakeTextChannel fakeTextChannel;
FakeTextInputClient client;
setUp(() {
fakeTextChannel = FakeTextChannel((MethodCall call) async {});
TextInput.setChannel(fakeTextChannel);
client = FakeTextInputClient();
});
tearDown(() {
TextInputConnection.debugResetId();
TextInput.setChannel(SystemChannels.textInput);
});
test('text input client handler responds to reattach with setClient', () async {
TextInput.attach(client, client.configuration);
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
]);
fakeTextChannel.incoming(const MethodCall('TextInputClient.requestExistingInputState', null));
expect(fakeTextChannel.outgoingCalls.length, 2);
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
// From original attach
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
// From requestExistingInputState
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
]);
});
test('text input client handler responds to reattach with setClient and text state', () async {
final TextInputConnection connection = TextInput.attach(client, client.configuration);
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
]);
const TextEditingValue editingState = TextEditingValue(text: 'foo');
connection.setEditingState(editingState);
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
MethodCall('TextInput.setEditingState', editingState.toJSON()),
]);
fakeTextChannel.incoming(const MethodCall('TextInputClient.requestExistingInputState', null));
expect(fakeTextChannel.outgoingCalls.length, 4);
fakeTextChannel.validateOutgoingMethodCalls(<MethodCall>[
// attach
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
// set editing state 1
MethodCall('TextInput.setEditingState', editingState.toJSON()),
// both from requestExistingInputState
MethodCall('TextInput.setClient', <dynamic>[1, client.configuration.toJson()]),
MethodCall('TextInput.setEditingState', editingState.toJSON()),
]);
});
});
group('TextInputConfiguration', () { group('TextInputConfiguration', () {
test('sets expected defaults', () { test('sets expected defaults', () {
const TextInputConfiguration configuration = TextInputConfiguration(); const TextInputConfiguration configuration = TextInputConfiguration();
...@@ -113,7 +175,7 @@ void main() { ...@@ -113,7 +175,7 @@ void main() {
}); });
} }
class FakeTextInputClient extends TextInputClient { class FakeTextInputClient implements TextInputClient {
String latestMethodCall = ''; String latestMethodCall = '';
@override @override
...@@ -135,4 +197,68 @@ class FakeTextInputClient extends TextInputClient { ...@@ -135,4 +197,68 @@ class FakeTextInputClient extends TextInputClient {
void connectionClosed() { void connectionClosed() {
latestMethodCall = 'connectionClosed'; latestMethodCall = 'connectionClosed';
} }
TextInputConfiguration get configuration => const TextInputConfiguration();
}
class FakeTextChannel implements MethodChannel {
FakeTextChannel(this.outgoing) : assert(outgoing != null);
Future<void> Function(MethodCall) outgoing;
Future<void> Function(MethodCall) incoming;
List<MethodCall> outgoingCalls = <MethodCall>[];
@override
BinaryMessenger get binaryMessenger => throw UnimplementedError();
@override
MethodCodec get codec => const JSONMethodCodec();
@override
Future<List<T>> invokeListMethod<T>(String method, [dynamic arguments]) => throw UnimplementedError();
@override
Future<Map<K, V>> invokeMapMethod<K, V>(String method, [dynamic arguments]) => throw UnimplementedError();
@override
Future<T> invokeMethod<T>(String method, [dynamic arguments]) {
final MethodCall call = MethodCall(method, arguments);
outgoingCalls.add(call);
return outgoing(call);
}
@override
String get name => 'flutter/textinput';
@override
void setMethodCallHandler(Future<void> Function(MethodCall call) handler) {
incoming = handler;
}
@override
void setMockMethodCallHandler(Future<void> Function(MethodCall call) handler) => throw UnimplementedError();
void validateOutgoingMethodCalls(List<MethodCall> calls) {
expect(outgoingCalls.length, calls.length);
bool hasError = false;
for (int i = 0; i < calls.length; i++) {
final ByteData outgoingData = codec.encodeMethodCall(outgoingCalls[i]);
final ByteData expectedData = codec.encodeMethodCall(calls[i]);
final String outgoingString = utf8.decode(outgoingData.buffer.asUint8List());
final String expectedString = utf8.decode(expectedData.buffer.asUint8List());
if (outgoingString != expectedString) {
print(
'Index $i did not match:\n'
' actual: ${outgoingCalls[i]}'
' expected: ${calls[i]}');
hasError = true;
}
}
if (hasError) {
fail('Calls did not match.');
}
}
} }
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