From fdeef1846565a360cd25133f29556cf52e396f8f Mon Sep 17 00:00:00 2001 From: Nurhan Turgut Date: Wed, 6 May 2020 12:58:46 -0700 Subject: [PATCH] Autofill main part (flutter/engine#17986) * changes for getting the configuration * running autofill * simplifications, remove unused map * more changes * make single autofill fields work. remove print messages * remove an extra line * remove extra file. also update chrome version * addressing reviewers comments * addressing reviewer comments * addressing reviewer comments * addressing reviewer comments * changing comments * changing comments * adding a comment on subscriptions lifecycle * fixing a bug which was failing the existing unit tests * add unit tests for AutofillInfo and EngineAutofillForm. add autocomplete to textarea * add unit tests for method channels * remove json from the end of the file * do not change the input type for the focused element * check name instead of autocomplete for firefox * check name instead of autocomplete for firefox in other methods as well * fixing a bug in the autofillhints file, testing if firefox is failing for username hint or for all autocomplete values * fix the breaking unit test --- .../flutter/lib/web_ui/dev/browser_lock.yaml | 2 +- .../lib/web_ui/lib/src/engine/keyboard.dart | 14 +- .../engine/text_editing/autofill_hint.dart | 2 +- .../src/engine/text_editing/input_type.dart | 1 - .../src/engine/text_editing/text_editing.dart | 305 +++++++++++++-- .../lib/web_ui/test/text_editing_test.dart | 358 ++++++++++++++++++ 6 files changed, 653 insertions(+), 29 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/dev/browser_lock.yaml b/engine/src/flutter/lib/web_ui/dev/browser_lock.yaml index 2bede5bcd22..535847dde9c 100644 --- a/engine/src/flutter/lib/web_ui/dev/browser_lock.yaml +++ b/engine/src/flutter/lib/web_ui/dev/browser_lock.yaml @@ -2,7 +2,7 @@ chrome: # It seems Chrome can't always release from the same build for all operating # systems, so we specify per-OS build number. Linux: 753189 - Mac: 735116 + Mac: 735194 Win: 735105 firefox: version: '72.0' diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/keyboard.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/keyboard.dart index 572d1189d30..2569a05a3ab 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/keyboard.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/keyboard.dart @@ -74,7 +74,13 @@ class Keyboard { /// Initializing with `0x0` which means no meta keys are pressed. int _lastMetaState = 0x0; - void _handleHtmlEvent(html.KeyboardEvent event) { + void _handleHtmlEvent(html.Event event) { + if (event is! html.KeyboardEvent) { + return; + } + + final html.KeyboardEvent keyboardEvent = event as html.KeyboardEvent; + if (window._onPlatformMessage == null) { return; } @@ -83,7 +89,7 @@ class Keyboard { event.preventDefault(); } - final String timerKey = event.code; + final String timerKey = keyboardEvent.code; // Don't synthesize a keyup event for modifier keys because the browser always // sends a keyup event for those. @@ -111,8 +117,8 @@ class Keyboard { final Map eventData = { 'type': event.type, 'keymap': 'web', - 'code': event.code, - 'key': event.key, + 'code': keyboardEvent.code, + 'key': keyboardEvent.key, 'metaState': _lastMetaState, }; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart index bd5f865c52e..2b28aee6777 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart @@ -37,7 +37,7 @@ class BrowserAutofillHints { 'creditCardSecurityCode': 'cc-csc', 'creditCardType': 'cc-type', 'email': 'email', - 'familyName': 'familyName', + 'familyName': 'family-name', 'fullStreetAddress': 'street-address', 'gender': 'sex', 'givenName': 'given-name', diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart index 9a82bfc504c..a3b46ee6db3 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart @@ -26,7 +26,6 @@ abstract class EngineInputType { return url; case 'TextInputType.multiline': return multiline; - case 'TextInputType.text': default: return text; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 64ed23f8a35..5fcee58e895 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -49,6 +49,221 @@ void _setStaticStyleAttributes(html.HtmlElement domElement) { } } +/// Sets attributes to hide autofill elements. +/// +/// These style attributes are constant throughout the life time of an input +/// element. +/// +/// They are assigned once during the creation of the DOM element. +void _hideAutofillElements(html.HtmlElement domElement) { + final html.CssStyleDeclaration elementStyle = domElement.style; + elementStyle + ..whiteSpace = 'pre-wrap' + ..alignContent = 'center' + ..padding = '0' + ..opacity = '1' + ..color = 'transparent' + ..backgroundColor = 'transparent' + ..background = 'transparent' + ..outline = 'none' + ..border = 'none' + ..resize = 'none' + ..textShadow = 'transparent' + ..transformOrigin = '0 0 0'; + + /// This property makes the input's blinking cursor transparent. + elementStyle.setProperty('caret-color', 'transparent'); +} + +/// Form that contains all the fields in the same AutofillGroup. +/// +/// These values are to be used when autofill is enabled and there is a group of +/// text fields with more than one text field. +class EngineAutofillForm { + EngineAutofillForm({this.formElement, this.elements, this.items}); + + final html.FormElement formElement; + + final Map elements; + + final Map items; + + factory EngineAutofillForm.fromFrameworkMessage( + Map focusedElementAutofill, + List fields, + ) { + // Autofill value can be null if focused text element does not have an + // autofill hint set. + if (focusedElementAutofill == null) { + return null; + } + + // If there is only one text field in the autofill model, `fields` will be + // null. `focusedElementAutofill` contains the information about the one + // text field. + final bool singleElement = (fields == null); + final AutofillInfo focusedElement = + AutofillInfo.fromFrameworkMessage(focusedElementAutofill); + final Map elements = {}; + final Map items = {}; + final html.FormElement formElement = html.FormElement(); + + // Validation is in the framework side. + formElement.noValidate = true; + + _hideAutofillElements(formElement); + + if (!singleElement) { + for (Map field in fields) { + final Map autofillInfo = field['autofill']; + final AutofillInfo autofill = + AutofillInfo.fromFrameworkMessage(autofillInfo); + + // The focused text editing element will not be created here. + if (autofill.uniqueIdentifier != focusedElement.uniqueIdentifier) { + EngineInputType engineInputType = + EngineInputType.fromName(field['inputType']['name']); + + html.HtmlElement htmlElement = engineInputType.createDomElement(); + autofill.editingState.applyToDomElement(htmlElement); + autofill.applyToDomElement(htmlElement); + _hideAutofillElements(htmlElement); + + items[autofill.uniqueIdentifier] = autofill; + elements[autofill.uniqueIdentifier] = htmlElement; + formElement.append(htmlElement); + } + } + } + + return EngineAutofillForm( + formElement: formElement, + elements: elements, + items: items, + ); + } + + void placeForm(html.HtmlElement mainTextEditingElement) { + formElement.append(mainTextEditingElement); + domRenderer.glassPaneElement.append(formElement); + } + + void removeForm() { + formElement.remove(); + } + + /// Listens to `onInput` event on the form fields. + /// + /// Registering to the listeners could have been done in the constructor. + /// On the other hand, overall for text editing there is already a lifecycle + /// for subscriptions: All the subscriptions of the DOM elements are to the + /// `_subscriptions` property of [DefaultTextEditingStrategy]. + /// [TextEditingStrategy] manages all subscription lifecyle. All + /// listeners with no exceptions are added during + /// [TextEditingStrategy.addEventHandlers] method call and all + /// listeners are removed during [TextEditingStrategy.disable] method call. + List> addInputEventListeners() { + Iterable keys = elements.keys; + List> subscriptions = + >[]; + keys.forEach((String key) { + final html.Element element = elements[key]; + subscriptions.add(element.onInput.listen((html.Event e) { + _handleChange(element, key); + })); + }); + return subscriptions; + } + + void _handleChange(html.Element domElement, String tag) { + EditingState newEditingState = EditingState.fromDomElement(domElement); + + _sendAutofillEditingState(tag, newEditingState); + } + + /// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework. + void _sendAutofillEditingState(String tag, EditingState editingState) { + if (window._onPlatformMessage != null) { + window.invokeOnPlatformMessage( + 'flutter/textinput', + const JSONMethodCodec().encodeMethodCall( + MethodCall( + 'TextInputClient.updateEditingStateWithTag', + [ + 0, + {tag: editingState.toFlutter()} + ], + ), + ), + _emptyCallback, + ); + } + } +} + +/// Autofill related values. +/// +/// These values are to be used when a text field have autofill enabled. +@visibleForTesting +class AutofillInfo { + AutofillInfo({this.editingState, this.uniqueIdentifier, this.hint}); + + /// The current text and selection state of a text field. + final EditingState editingState; + + /// Unique value set by the developer. + /// + /// Used as id of the text field. + final String uniqueIdentifier; + + /// Attribute used for autofill. + /// + /// Used as a guidance to the browser as to the type of information expected + /// in the field. + /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete + final String hint; + + factory AutofillInfo.fromFrameworkMessage(Map autofill) { + // Autofill value can be null if no TextFields is set with autofill hint. + if (autofill == null) { + return null; + } + + final String uniqueIdentifier = autofill['uniqueIdentifier']; + final List hintsList = autofill['hints']; + final EditingState editingState = + EditingState.fromFrameworkMessage(autofill['editingValue']); + return AutofillInfo( + uniqueIdentifier: uniqueIdentifier, + hint: BrowserAutofillHints.instance.flutterToEngine(hintsList[0]), + editingState: editingState); + } + + void applyToDomElement(html.HtmlElement domElement, + {bool focusedElement = false}) { + domElement.id = hint; + if (domElement is html.InputElement) { + html.InputElement element = domElement; + element.name = hint; + element.id = uniqueIdentifier; + element.autocomplete = hint; + // Do not change the element type for the focused element. + if (focusedElement == false) { + if (hint.contains('password')) { + element.type = 'password'; + } else { + element.type = 'text'; + } + } + } else if (domElement is html.TextAreaElement) { + html.TextAreaElement element = domElement; + element.name = hint; + element.id = uniqueIdentifier; + element.setAttribute('autocomplete', hint); + } + } +} + /// The current text and selection state of a text field. @visibleForTesting class EditingState { @@ -73,7 +288,8 @@ class EditingState { /// Flutter Framework can send the [selectionBase] and [selectionExtent] as /// -1, if so 0 assigned to the [baseOffset] and [extentOffset]. -1 is not a /// valid selection range for input DOM elements. - factory EditingState.fromFrameworkMessage(Map flutterEditingState) { + factory EditingState.fromFrameworkMessage( + Map flutterEditingState) { final int selectionBase = flutterEditingState['selectionBase']; final int selectionExtent = flutterEditingState['selectionExtent']; final String text = flutterEditingState['text']; @@ -183,14 +399,21 @@ class InputConfiguration { @required this.inputAction, @required this.obscureText, @required this.autocorrect, + this.autofill, + this.autofillGroup, }); - - InputConfiguration.fromFrameworkMessage(Map flutterInputConfiguration) + InputConfiguration.fromFrameworkMessage( + Map flutterInputConfiguration) : inputType = EngineInputType.fromName( flutterInputConfiguration['inputType']['name']), inputAction = flutterInputConfiguration['inputAction'], obscureText = flutterInputConfiguration['obscureText'], - autocorrect = flutterInputConfiguration['autocorrect']; + autocorrect = flutterInputConfiguration['autocorrect'], + autofill = AutofillInfo.fromFrameworkMessage( + flutterInputConfiguration['autofill']), + autofillGroup = EngineAutofillForm.fromFrameworkMessage( + flutterInputConfiguration['autofill'], + flutterInputConfiguration['fields']); /// The type of information being edited in the input control. final EngineInputType inputType; @@ -209,6 +432,10 @@ class InputConfiguration { /// For future manual tests, note that autocorrect is an attribute only /// supported by Safari. final bool autocorrect; + + final AutofillInfo autofill; + + final EngineAutofillForm autofillGroup; } typedef _OnChangeCallback = void Function(EditingState editingState); @@ -330,21 +557,29 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { }) { assert(!isEnabled); + this._inputConfiguration = inputConfig; + domElement = inputConfig.inputType.createDomElement(); if (inputConfig.obscureText) { domElement.setAttribute('type', 'password'); } + inputConfig.autofill?.applyToDomElement(domElement, focusedElement: true); + final String autocorrectValue = inputConfig.autocorrect ? 'on' : 'off'; domElement.setAttribute('autocorrect', autocorrectValue); _setStaticStyleAttributes(domElement); _style?.applyToDomElement(domElement); + if (_inputConfiguration.autofillGroup != null) { + _inputConfiguration.autofillGroup.placeForm(domElement); + } else { + domRenderer.glassPaneElement.append(domElement); + } + initializeElementPlacement(); - domRenderer.glassPaneElement.append(domElement); isEnabled = true; - _inputConfiguration = inputConfig; _onChange = onChange; _onAction = onAction; } @@ -356,6 +591,11 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { @override void addEventHandlers() { + if (_inputConfiguration.autofillGroup != null) { + _subscriptions + .addAll(_inputConfiguration.autofillGroup.addInputEventListeners()); + } + // Subscribe to text and selection changes. _subscriptions.add(domElement.onInput.listen(_handleChange)); @@ -425,6 +665,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { _subscriptions.clear(); domElement.remove(); domElement = null; + _inputConfiguration.autofillGroup?.removeForm(); } @mustCallSuper @@ -458,11 +699,13 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { } } - void _maybeSendAction(html.KeyboardEvent event) { - if (_inputConfiguration.inputType.submitActionOnEnter && - event.keyCode == _kReturnKeyCode) { - event.preventDefault(); - _onAction(_inputConfiguration.inputAction); + void _maybeSendAction(html.Event event) { + if (event is html.KeyboardEvent) { + if (_inputConfiguration.inputType.submitActionOnEnter && + event.keyCode == _kReturnKeyCode) { + event.preventDefault(); + _onAction(_inputConfiguration.inputAction); + } } } @@ -581,6 +824,11 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { @override void addEventHandlers() { + if (_inputConfiguration.autofillGroup != null) { + _subscriptions + .addAll(_inputConfiguration.autofillGroup.addInputEventListeners()); + } + // Subscribe to text and selection changes. _subscriptions.add(domElement.onInput.listen(_handleChange)); @@ -594,7 +842,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { _schedulePlacement(); })); - _addTapListener(); + _addTapListener(); // On iOS, blur is trigerred if the virtual keyboard is closed or the // browser is sent to background or the browser tab is changed. @@ -685,6 +933,11 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { @override void addEventHandlers() { + if (_inputConfiguration.autofillGroup != null) { + _subscriptions + .addAll(_inputConfiguration.autofillGroup.addInputEventListeners()); + } + // Subscribe to text and selection changes. _subscriptions.add(domElement.onInput.listen(_handleChange)); @@ -715,6 +968,11 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { @override void addEventHandlers() { + if (_inputConfiguration.autofillGroup != null) { + _subscriptions + .addAll(_inputConfiguration.autofillGroup.addInputEventListeners()); + } + // Subscribe to text and selection changes. _subscriptions.add(domElement.onInput.listen(_handleChange)); @@ -780,8 +1038,7 @@ class TextEditingChannel { /// Handles "flutter/textinput" platform messages received from the framework. void handleTextInput( - ByteData data, - ui.PlatformMessageResponseCallback callback) { + ByteData data, ui.PlatformMessageResponseCallback callback) { const JSONMethodCodec codec = JSONMethodCodec(); final MethodCall call = codec.decodeMethodCall(data); switch (call.method) { @@ -793,7 +1050,8 @@ class TextEditingChannel { break; case 'TextInput.setEditingState': - implementation.setEditingState(EditingState.fromFrameworkMessage(call.arguments)); + implementation + .setEditingState(EditingState.fromFrameworkMessage(call.arguments)); break; case 'TextInput.show': @@ -801,11 +1059,13 @@ class TextEditingChannel { break; case 'TextInput.setEditableSizeAndTransform': - implementation.setEditableSizeAndTransform(EditableTextGeometry.fromFrameworkMessage(call.arguments)); + implementation.setEditableSizeAndTransform( + EditableTextGeometry.fromFrameworkMessage(call.arguments)); break; case 'TextInput.setStyle': - implementation.setStyle(EditableTextStyle.fromFrameworkMessage(call.arguments)); + implementation + .setStyle(EditableTextStyle.fromFrameworkMessage(call.arguments)); break; case 'TextInput.clearClient': @@ -822,7 +1082,8 @@ class TextEditingChannel { break; default: - throw StateError('Unsupported method call on the flutter/textinput channel: ${call.method}'); + throw StateError( + 'Unsupported method call on the flutter/textinput channel: ${call.method}'); } window._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); } @@ -941,8 +1202,7 @@ class HybridTextEditing { /// Responds to the 'TextInput.setEditingState' message. void setEditingState(EditingState state) { - editingElement - .setEditingState(state); + editingElement.setEditingState(state); } /// Responds to the 'TextInput.show' message. @@ -1021,7 +1281,7 @@ class HybridTextEditing { }, onAction: (String inputAction) { channel.performAction(_clientId, inputAction); - } + }, ); } @@ -1051,7 +1311,8 @@ class EditableTextStyle { @required this.fontWeight, }); - factory EditableTextStyle.fromFrameworkMessage(Map flutterStyle) { + factory EditableTextStyle.fromFrameworkMessage( + Map flutterStyle) { assert(flutterStyle.containsKey('fontSize')); assert(flutterStyle.containsKey('fontFamily')); assert(flutterStyle.containsKey('textAlignIndex')); diff --git a/engine/src/flutter/lib/web_ui/test/text_editing_test.dart b/engine/src/flutter/lib/web_ui/test/text_editing_test.dart index 6b64aaef49e..dbc760c05ab 100644 --- a/engine/src/flutter/lib/web_ui/test/text_editing_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text_editing_test.dart @@ -787,6 +787,85 @@ void main() { expect(spy.messages, isEmpty); }); + test( + 'singleTextField Autofill: setClient, setEditingState, show, ' + 'setEditingState, clearClient', () { + // Create a configuration with focused element has autofil hint. + final Map flutterSingleAutofillElementConfig = + createFlutterConfig('text', autofillHint: 'username'); + final MethodCall setClient = MethodCall('TextInput.setClient', + [123, flutterSingleAutofillElementConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The second [setEditingState] should override the first one. + checkInputEditingState( + textEditing.editingElement.domElement, 'abcd', 2, 3); + + final FormElement formElement = document.getElementsByTagName('form')[0]; + expect(formElement.childNodes, hasLength(1)); + + const MethodCall clearClient = MethodCall('TextInput.clearClient'); + sendFrameworkMessage(codec.encodeMethodCall(clearClient)); + + // Confirm that [HybridTextEditing] didn't send any messages. + expect(spy.messages, isEmpty); + expect(document.getElementsByTagName('form'), isEmpty); + }); + + test( + 'multiTextField Autofill: setClient, setEditingState, show, ' + 'setEditingState, clearClient', () { + // Create a configuration with an AutofillGroup of four text fields. + final Map flutterMultiAutofillElementConfig = + createFlutterConfig('text', + autofillHint: 'username', + autofillHintsForFields: [ + 'username', + 'email', + 'name', + 'telephoneNumber' + ]); + final MethodCall setClient = MethodCall('TextInput.setClient', + [123, flutterMultiAutofillElementConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The second [setEditingState] should override the first one. + checkInputEditingState( + textEditing.editingElement.domElement, 'abcd', 2, 3); + + final FormElement formElement = document.getElementsByTagName('form')[0]; + expect(formElement.childNodes, hasLength(4)); + + const MethodCall clearClient = MethodCall('TextInput.clearClient'); + sendFrameworkMessage(codec.encodeMethodCall(clearClient)); + + // Confirm that [HybridTextEditing] didn't send any messages. + expect(spy.messages, isEmpty); + expect(document.getElementsByTagName('form'), isEmpty); + }); + test( 'setClient, setEditableSizeAndTransform, setStyle, setEditingState, show, clearClient', () { @@ -1040,6 +1119,74 @@ void main() { hideKeyboard(); }); + test('multiTextField Autofill sync updates back to Flutter', () { + // Create a configuration with an AutofillGroup of four text fields. + final String hintForFirstElement = 'familyName'; + final Map flutterMultiAutofillElementConfig = + createFlutterConfig('text', + autofillHint: 'email', + autofillHintsForFields: [ + hintForFirstElement, + 'email', + 'givenName', + 'telephoneNumber' + ]); + final MethodCall setClient = MethodCall('TextInput.setClient', + [123, flutterMultiAutofillElementConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The second [setEditingState] should override the first one. + checkInputEditingState( + textEditing.editingElement.domElement, 'abcd', 2, 3); + + final FormElement formElement = document.getElementsByTagName('form')[0]; + expect(formElement.childNodes, hasLength(4)); + + // Autofill one of the form elements. + InputElement element = formElement.childNodes.first; + if (browserEngine == BrowserEngine.firefox) { + expect(element.name, + BrowserAutofillHints.instance.flutterToEngine(hintForFirstElement)); + } else { + expect(element.autocomplete, + BrowserAutofillHints.instance.flutterToEngine(hintForFirstElement)); + } + element.value = 'something'; + element.dispatchEvent(Event.eventType('Event', 'input')); + + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/textinput'); + expect(spy.messages[0].methodName, + 'TextInputClient.updateEditingStateWithTag'); + expect( + spy.messages[0].methodArguments, + [ + 0, // Client ID + { + hintForFirstElement: { + 'text': 'something', + 'selectionBase': 9, + 'selectionExtent': 9 + } + }, + ], + ); + + spy.messages.clear(); + hideKeyboard(); + }); + test('Multi-line mode also works', () { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterMultilineConfig]); @@ -1215,6 +1362,169 @@ void main() { }); }); + group('EngineAutofillForm', () { + test('validate multi element form', () { + final List fields = createFieldValues( + ['username', 'password', 'newPassword'], + ['field1', 'fields2', 'field3']); + final EngineAutofillForm autofillForm = + EngineAutofillForm.fromFrameworkMessage( + createAutofillInfo('username', 'field1'), fields); + + // Number of elements if number of fields sent to the constructor minus + // one (for the focused text element). + expect(autofillForm.elements, hasLength(2)); + expect(autofillForm.items, hasLength(2)); + expect(autofillForm.formElement, isNotNull); + + final FormElement form = autofillForm.formElement; + expect(form.childNodes, hasLength(2)); + + final InputElement firstElement = form.childNodes.first; + // Autofill value is applied to the element. + expect(firstElement.name, + BrowserAutofillHints.instance.flutterToEngine('password')); + expect(firstElement.id, 'fields2'); + expect(firstElement.type, 'password'); + if (browserEngine == BrowserEngine.firefox) { + expect(firstElement.name, + BrowserAutofillHints.instance.flutterToEngine('password')); + } else { + expect(firstElement.autocomplete, + BrowserAutofillHints.instance.flutterToEngine('password')); + } + + // Editing state is applied to the element. + expect(firstElement.value, 'Test'); + expect(firstElement.selectionStart, 0); + expect(firstElement.selectionEnd, 0); + + // Element is hidden. + final CssStyleDeclaration css = firstElement.style; + expect(css.color, 'transparent'); + expect(css.backgroundColor, 'transparent'); + }); + + test('place remove form', () { + final List fields = createFieldValues( + ['username', 'password', 'newPassword'], + ['field1', 'fields2', 'field3']); + final EngineAutofillForm autofillForm = + EngineAutofillForm.fromFrameworkMessage( + createAutofillInfo('username', 'field1'), fields); + + final InputElement testInputElement = InputElement(); + autofillForm.placeForm(testInputElement); + + // The focused element is appended to the form, + final FormElement form = autofillForm.formElement; + expect(form.childNodes, hasLength(3)); + + final FormElement formOnDom = document.getElementsByTagName('form')[0]; + // Form is attached to the DOM. + expect(form, equals(formOnDom)); + + autofillForm.removeForm(); + expect(document.getElementsByTagName('form'), isEmpty); + }); + + test('Validate single element form', () { + final List fields = createFieldValues(['username'], ['field1']); + final EngineAutofillForm autofillForm = + EngineAutofillForm.fromFrameworkMessage( + createAutofillInfo('username', 'field1'), fields); + + // The focused element is the only field. Form should be empty after + // the initialization (focus element is appended later). + expect(autofillForm.elements, isEmpty); + expect(autofillForm.items, isEmpty); + expect(autofillForm.formElement, isNotNull); + + final FormElement form = autofillForm.formElement; + expect(form.childNodes, isEmpty); + }); + + test('Return null if no focused element', () { + final List fields = createFieldValues(['username'], ['field1']); + final EngineAutofillForm autofillForm = + EngineAutofillForm.fromFrameworkMessage(null, fields); + + expect(autofillForm, isNull); + }); + }); + + group('AutofillInfo', () { + const String testHint = 'streetAddressLine2'; + const String testId = 'EditableText-659836579'; + const String testPasswordHint = 'password'; + + test('autofill has correct value', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(testHint, testId)); + + // Hint sent from the framework is converted to the hint compatible with + // browsers. + expect(autofillInfo.hint, + BrowserAutofillHints.instance.flutterToEngine(testHint)); + expect(autofillInfo.uniqueIdentifier, testId); + }); + + test('input with autofill hint', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(testHint, testId)); + + final InputElement testInputElement = InputElement(); + autofillInfo.applyToDomElement(testInputElement); + + // Hint sent from the framework is converted to the hint compatible with + // browsers. + expect(testInputElement.name, + BrowserAutofillHints.instance.flutterToEngine(testHint)); + expect(testInputElement.id, testId); + expect(testInputElement.type, 'text'); + if (browserEngine == BrowserEngine.firefox) { + expect(testInputElement.name, + BrowserAutofillHints.instance.flutterToEngine(testHint)); + } else { + expect(testInputElement.autocomplete, + BrowserAutofillHints.instance.flutterToEngine(testHint)); + } + }); + + test('textarea with autofill hint', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(testHint, testId)); + + final TextAreaElement testInputElement = TextAreaElement(); + autofillInfo.applyToDomElement(testInputElement); + + // Hint sent from the framework is converted to the hint compatible with + // browsers. + expect(testInputElement.name, + BrowserAutofillHints.instance.flutterToEngine(testHint)); + expect(testInputElement.id, testId); + expect(testInputElement.getAttribute('autocomplete'), + BrowserAutofillHints.instance.flutterToEngine(testHint)); + }); + + test('password autofill hint', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(testPasswordHint, testId)); + + final InputElement testInputElement = InputElement(); + autofillInfo.applyToDomElement(testInputElement); + + // Hint sent from the framework is converted to the hint compatible with + // browsers. + expect(testInputElement.name, + BrowserAutofillHints.instance.flutterToEngine(testPasswordHint)); + expect(testInputElement.id, testId); + expect(testInputElement.type, 'password'); + expect(testInputElement.getAttribute('autocomplete'), + BrowserAutofillHints.instance.flutterToEngine(testPasswordHint)); + }); + }); + group('EditingState', () { EditingState _editingState; @@ -1399,11 +1709,17 @@ void checkTextAreaEditingState( expect(textarea.selectionEnd, end); } +/// Creates an [InputConfiguration] for using in the tests. +/// +/// For simplicity this method is using `autofillHint` as the `uniqueId` for +/// simplicity. Map createFlutterConfig( String inputType, { bool obscureText = false, bool autocorrect = true, String inputAction, + String autofillHint, + List autofillHintsForFields, }) { return { 'inputType': { @@ -1412,5 +1728,47 @@ Map createFlutterConfig( 'obscureText': obscureText, 'autocorrect': autocorrect, 'inputAction': inputAction ?? 'TextInputAction.done', + if (autofillHint != null) + 'autofill': createAutofillInfo(autofillHint, autofillHint), + if (autofillHintsForFields != null) + 'fields': + createFieldValues(autofillHintsForFields, autofillHintsForFields), }; } + +Map createAutofillInfo(String hint, String uniqueId) => + { + 'uniqueIdentifier': uniqueId, + 'hints': [hint], + 'editingValue': { + 'text': 'Test', + 'selectionBase': 0, + 'selectionExtent': 0, + 'selectionAffinity': 'TextAffinity.downstream', + 'selectionIsDirectional': false, + 'composingBase': -1, + 'composingExtent': -1, + }, + }; + +List createFieldValues(List hints, List uniqueIds) { + final List testFields = []; + + expect(hints.length, equals(uniqueIds.length)); + + for (int i = 0; i < hints.length; i++) { + testFields.add(createOneFieldValue(hints[i], uniqueIds[i])); + } + + return testFields; +} + +Map createOneFieldValue(String hint, String uniqueId) => + { + 'inputType': { + 'name': 'TextInputType.text', + 'signed': null, + 'decimal': null + }, + 'autofill': createAutofillInfo(hint, uniqueId) + };