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 {
/// second argument is a [String] consisting of the stringification of one
/// 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
/// on the platform side. For example the application is moved to
/// background or used closed the virtual keyboard. This method informs
......
......@@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4;
import 'message_codec.dart';
import 'platform_channel.dart';
import 'system_channels.dart';
import 'system_chrome.dart';
import 'text_editing.dart';
......@@ -673,24 +674,34 @@ class TextInputConnection {
static int _nextId = 1;
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;
/// 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.
void show() {
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.
void setEditingState(TextEditingValue value) {
assert(attached);
SystemChannels.textInput.invokeMethod<void>(
'TextInput.setEditingState',
value.toJSON(),
);
TextInput._instance._setEditingState(value);
}
/// Send the size and transform of the editable text to engine.
......@@ -706,8 +717,7 @@ class TextInputConnection {
if (editableBoxSize != _cachedSize || transform != _cachedTransform) {
_cachedSize = editableBoxSize;
_cachedTransform = transform;
SystemChannels.textInput.invokeMethod<void>(
'TextInput.setEditableSizeAndTransform',
TextInput._instance._setEditableSizeAndTransform(
<String, dynamic>{
'width': editableBoxSize.width,
'height': editableBoxSize.height,
......@@ -731,8 +741,7 @@ class TextInputConnection {
}) {
assert(attached);
SystemChannels.textInput.invokeMethod<void>(
'TextInput.setStyle',
TextInput._instance._setStyle(
<String, dynamic>{
'fontFamily': fontFamily,
'fontSize': fontSize,
......@@ -749,10 +758,7 @@ class TextInputConnection {
/// other client attaches to it within this animation frame.
void close() {
if (attached) {
SystemChannels.textInput.invokeMethod<void>('TextInput.clearClient');
_clientHandler
.._currentConnection = null
.._scheduleHide();
TextInput._instance._clearClient();
}
assert(!attached);
}
......@@ -761,7 +767,7 @@ class TextInputConnection {
///
/// [TextInputConnection] should clean current client connection.
void connectionClosedReceived() {
_clientHandler._currentConnection = null;
TextInput._instance._currentConnection = null;
assert(!attached);
}
}
......@@ -818,63 +824,28 @@ RawFloatingCursorPoint _toTextPoint(FloatingCursorDragState state, Map<String, d
return RawFloatingCursorPoint(offset: offset, state: state);
}
class _TextInputClientHandler {
_TextInputClientHandler() {
SystemChannels.textInput.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();
}
/// An interface to the system's text input control.
class TextInput {
TextInput._() {
_channel = SystemChannels.textInput;
_channel.setMethodCallHandler(_handleTextInputInvocation);
}
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)
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
});
/// Set the [MethodChannel] used to communicate with the system's text input
/// control.
///
/// This is only meant for testing within the Flutter SDK. Changing this
/// will break the ability to input text. This has no effect if asserts are
/// disabled.
@visibleForTesting
static void setChannel(MethodChannel newChannel) {
assert(() {
_instance._channel = newChannel..setMethodCallHandler(_instance._handleTextInputInvocation);
return true;
}());
}
}
final _TextInputClientHandler _clientHandler = _TextInputClientHandler();
/// An interface to the system's text input control.
class TextInput {
TextInput._();
static final TextInput _instance = TextInput._();
static const List<TextInputAction> _androidSupportedInputActions = <TextInputAction>[
TextInputAction.none,
......@@ -915,14 +886,25 @@ class TextInput {
static TextInputConnection attach(TextInputClient client, TextInputConfiguration configuration) {
assert(client != null);
assert(configuration != null);
assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction));
final TextInputConnection connection = TextInputConnection._(client);
_clientHandler._currentConnection = connection;
SystemChannels.textInput.invokeMethod<void>(
_instance._attach(connection, configuration);
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',
<dynamic>[ connection._id, configuration.toJson() ],
);
return connection;
_currentConnection = connection;
_currentConfiguration = configuration;
}
static bool _debugEnsureInputActionWorksOnPlatform(TextInputAction inputAction) {
......@@ -946,4 +928,100 @@ class TextInput {
}());
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
if (!_hasInputConnection) {
final TextEditingValue localValue = _value;
_lastKnownRemoteTextEditingValue = localValue;
_textInputConnection = TextInput.attach(this,
TextInputConfiguration(
inputType: widget.keyboardType,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
enableSuggestions: widget.enableSuggestions,
inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline
? TextInputAction.newline
: TextInputAction.done
),
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: widget.keyboardType,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
enableSuggestions: widget.enableSuggestions,
inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline
? TextInputAction.newline
: TextInputAction.done
),
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
),
);
_textInputConnection.show();
......
......@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert' show utf8;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding;
import '../flutter_test_alternative.dart';
......@@ -9,6 +11,66 @@ import '../flutter_test_alternative.dart';
void main() {
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', () {
test('sets expected defaults', () {
const TextInputConfiguration configuration = TextInputConfiguration();
......@@ -113,7 +175,7 @@ void main() {
});
}
class FakeTextInputClient extends TextInputClient {
class FakeTextInputClient implements TextInputClient {
String latestMethodCall = '';
@override
......@@ -135,4 +197,68 @@ class FakeTextInputClient extends TextInputClient {
void 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