diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 99860c3a02f67..3eee473f2894d 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -129,14 +129,16 @@ void _hideAutofillElements(html.HtmlElement domElement, /// 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. +/// An [EngineAutofillForm] will only be constructed when autofill is enabled +/// (the default) on the current input field. See the [fromFrameworkMessage] +/// static method. class EngineAutofillForm { - EngineAutofillForm( - {required this.formElement, - this.elements, - this.items, - this.formIdentifier = ''}); + EngineAutofillForm({ + required this.formElement, + this.elements, + this.items, + this.formIdentifier = '', + }); final html.FormElement formElement; @@ -153,12 +155,23 @@ class EngineAutofillForm { /// See [formsOnTheDom]. final String formIdentifier; + /// Creates an [EngineAutofillFrom] from the JSON representation of a Flutter + /// framework `TextInputConfiguration` object. + /// + /// The `focusedElementAutofill` argument corresponds to the "autofill" field + /// in a `TextInputConfiguration`. Not having this field indicates autofill + /// is explicitly disabled on the text field by the developer. + /// + /// The `fields` argument corresponds to the "fields" field in a + /// `TextInputConfiguration`. + /// + /// Returns null if autofill is disabled for the input field. static EngineAutofillForm? fromFrameworkMessage( Map? focusedElementAutofill, List? fields, ) { - // Autofill value can be null if focused text element does not have an - // autofill hint set. + // Autofill value will be null if the developer explicitly disables it on + // the input field. if (focusedElementAutofill == null) { return null; } @@ -287,7 +300,7 @@ class EngineAutofillForm { element.onInput.listen((html.Event e) { if (items![key] == null) { throw StateError( - 'Autofill would not work withuot Autofill value set'); + 'AutofillInfo must have a valid uniqueIdentifier.'); } else { final AutofillInfo autofillInfo = items![key]!; handleChange(element, autofillInfo); @@ -330,11 +343,13 @@ class EngineAutofillForm { /// These values are to be used when a text field have autofill enabled. @visibleForTesting class AutofillInfo { - AutofillInfo( - {required this.editingState, - required this.uniqueIdentifier, - required this.hint, - required this.textCapitalization}); + AutofillInfo({ + required this.editingState, + required this.uniqueIdentifier, + required this.autofillHint, + required this.textCapitalization, + this.placeholder, + }); /// The current text and selection state of a text field. final EditingState editingState; @@ -359,47 +374,67 @@ class AutofillInfo { /// other the focused field, we need to use this information. final TextCapitalizationConfig textCapitalization; - /// Attribute used for autofill. + /// The type of information expected in the field, specified by the developer. /// /// 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; + final String? autofillHint; + + /// The optional hint text placed on the view that typically suggests what + /// sort of input the field accepts, for example "enter your password here". + /// + /// If the developer does not specify any [autofillHints], the [placeholder] + /// can be a useful indication to the platform autofill service as to what + /// information is expected in this field. + final String? placeholder; factory AutofillInfo.fromFrameworkMessage(Map autofill, {TextCapitalizationConfig textCapitalization = const TextCapitalizationConfig.defaultCapitalization()}) { assert(autofill != null); // ignore: unnecessary_null_comparison final String uniqueIdentifier = autofill.readString('uniqueIdentifier'); - final List hintsList = autofill.readList('hints'); + final List? hintsList = autofill.tryList('hints'); + final String? firstHint = (hintsList == null || hintsList.isEmpty) ? null : hintsList.first as String; final EditingState editingState = EditingState.fromFrameworkMessage(autofill.readJson('editingValue')); return AutofillInfo( uniqueIdentifier: uniqueIdentifier, - hint: BrowserAutofillHints.instance.flutterToEngine(hintsList[0] as String), + autofillHint: (firstHint != null) ? BrowserAutofillHints.instance.flutterToEngine(firstHint) : null, editingState: editingState, + placeholder: autofill.tryString('hintText'), textCapitalization: textCapitalization, ); } void applyToDomElement(html.HtmlElement domElement, {bool focusedElement = false}) { - domElement.id = hint; + final String? autofillHint = this.autofillHint; + final String? placeholder = this.placeholder; if (domElement is html.InputElement) { final html.InputElement element = domElement; - element.name = hint; - element.id = hint; - element.autocomplete = hint; - if (hint.contains('password')) { - element.type = 'password'; - } else { - element.type = 'text'; + if (placeholder != null) { + element.placeholder = placeholder; } + if (autofillHint != null) { + element.name = autofillHint; + element.id = autofillHint; + if (autofillHint.contains('password')) { + element.type = 'password'; + } else { + element.type = 'text'; + } + } + element.autocomplete = autofillHint ?? 'on'; } else if (domElement is html.TextAreaElement) { - final html.TextAreaElement element = domElement; - element.name = hint; - element.id = hint; - element.setAttribute('autocomplete', hint); + if (placeholder != null) { + domElement.placeholder = placeholder; + } + if (autofillHint != null) { + domElement.name = autofillHint; + domElement.id = autofillHint; + } + domElement.setAttribute('autocomplete', autofillHint ?? 'on'); } } } @@ -691,15 +726,13 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy { @override void placeElement() { + geometry?.applyToDomElement(activeDomElement); if (hasAutofillGroup) { - geometry?.applyToDomElement(focusedFormElement!); placeForm(); // Set the last editing state if it exists, this is critical for a // users ongoing work to continue uninterrupted when there is an update to // the transform. - if (lastEditingState != null) { - lastEditingState!.applyToDomElement(domElement); - } + lastEditingState?.applyToDomElement(domElement); // On Chrome, when a form is focused, it opens an autofill menu // immediately. // Flutter framework sends `setEditableSizeAndTransform` for informing @@ -712,8 +745,6 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy { // Refocus on the elements after applying the geometry. focusedFormElement!.focus(); activeDomElement.focus(); - } else { - geometry?.applyToDomElement(activeDomElement); } } } @@ -762,9 +793,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy { // the transform. // If domElement is not focused cursor location will not be correct. activeDomElement.focus(); - if (lastEditingState != null) { - lastEditingState!.applyToDomElement(activeDomElement); - } + lastEditingState?.applyToDomElement(activeDomElement); } } @@ -888,7 +917,12 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { activeDomElement.setAttribute('inputmode', 'none'); } - config.autofill?.applyToDomElement(activeDomElement, focusedElement: true); + final AutofillInfo? autofill = config.autofill; + if (autofill != null) { + autofill.applyToDomElement(activeDomElement, focusedElement: true); + } else { + activeDomElement.setAttribute('autocomplete', 'off'); + } final String autocorrectValue = config.autocorrect ? 'on' : 'off'; activeDomElement.setAttribute('autocorrect', autocorrectValue); @@ -1366,9 +1400,7 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // Set the last editing state if it exists, this is critical for a // users ongoing work to continue uninterrupted when there is an update to // the transform. - if (lastEditingState != null) { - lastEditingState!.applyToDomElement(activeDomElement); - } + lastEditingState?.applyToDomElement(activeDomElement); } } @@ -1753,11 +1785,9 @@ class HybridTextEditing { /// /// The constructor also decides which text editing strategy to use depending /// on the operating system and browser engine. - HybridTextEditing() { - channel = TextEditingChannel(this); - } + HybridTextEditing(); - late TextEditingChannel channel; + late final TextEditingChannel channel = TextEditingChannel(this); /// A CSS class name used to identify all elements used for text editing. @visibleForTesting diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index acc84cef5e1e4..e9f09b061bfa3 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -188,6 +188,23 @@ void testMain() { editingStrategy!.disable(); }); + test('Knows to turn autofill off', () { + final InputConfiguration config = InputConfiguration( + autofill: null, + ); + editingStrategy!.enable( + config, + onChange: trackEditingState, + onAction: trackInputAction, + ); + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + final Element input = defaultTextEditingRoot.querySelector('input')!; + expect(editingStrategy!.domElement, input); + expect(input.getAttribute('autocomplete'), 'off'); + + editingStrategy!.disable(); + }); + test('Can read editing state correctly', () { editingStrategy!.enable( singlelineConfig, @@ -462,6 +479,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + checkInputEditingState(textEditing!.strategy.domElement, '', 0, 0); const MethodCall setEditingState = @@ -504,6 +530,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -577,6 +612,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); expect(textEditing!.isEditing, isTrue); @@ -614,6 +658,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -826,6 +879,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -859,6 +921,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + const MethodCall setEditingState2 = MethodCall('TextInput.setEditingState', { 'text': 'xyz', @@ -899,6 +970,10 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(150, 50, Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); @@ -943,6 +1018,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(10, 10, + Matrix4.translationValues(10.0, 10.0, 10.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + final InputElement inputElement = textEditing!.strategy.domElement! as InputElement; expect(inputElement.value, 'abcd'); @@ -958,10 +1042,10 @@ void testMain() { // The transform is changed. For example after a validation error, red // line appeared under the input field. - final MethodCall setSizeAndTransform = + final MethodCall updateSizeAndTransform = configureSetSizeAndTransformMethodCall(150, 50, Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); - sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + sendFrameworkMessage(codec.encodeMethodCall(updateSizeAndTransform)); // Check the element still has focus. User can keep editing. expect(defaultTextEditingRoot.activeElement, @@ -1009,6 +1093,10 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(150, 50, Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); @@ -1166,6 +1254,10 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall( 150, @@ -1311,6 +1403,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + // Check if the selection range is correct. checkInputEditingState( textEditing!.strategy.domElement, 'xyz', 1, 2); @@ -1421,6 +1522,10 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(150, 50, Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); @@ -1479,6 +1584,15 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + final TextAreaElement textarea = textEditing!.strategy.domElement! as TextAreaElement; checkTextAreaEditingState(textarea, '', 0, 0); @@ -1816,7 +1930,7 @@ void testMain() { // Hint sent from the framework is converted to the hint compatible with // browsers. - expect(autofillInfo.hint, + expect(autofillInfo.autofillHint, BrowserAutofillHints.instance.flutterToEngine(testHint)); expect(autofillInfo.uniqueIdentifier, testId); }); @@ -1878,6 +1992,39 @@ void testMain() { expect(testInputElement.getAttribute('autocomplete'), BrowserAutofillHints.instance.flutterToEngine(testPasswordHint)); }); + + test('autofill with no hints', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(null, testId)); + + final InputElement testInputElement = InputElement(); + autofillInfo.applyToDomElement(testInputElement); + + expect(testInputElement.autocomplete,'on'); + expect(testInputElement.placeholder, isEmpty); + }); + + test('TextArea autofill with no hints', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(null, testId)); + + final TextAreaElement testInputElement = TextAreaElement(); + autofillInfo.applyToDomElement(testInputElement); + + expect(testInputElement.getAttribute('autocomplete'),'on'); + expect(testInputElement.placeholder, isEmpty); + }); + + test('autofill with only placeholder', () { + final AutofillInfo autofillInfo = AutofillInfo.fromFrameworkMessage( + createAutofillInfo(null, testId, placeholder: 'enter your password')); + + final TextAreaElement testInputElement = TextAreaElement(); + autofillInfo.applyToDomElement(testInputElement); + + expect(testInputElement.getAttribute('autocomplete'),'on'); + expect(testInputElement.placeholder, 'enter your password'); + }); }); group('EditingState', () { @@ -2097,7 +2244,9 @@ Map createFlutterConfig( bool autocorrect = true, String textCapitalization = 'TextCapitalization.none', String? inputAction, + bool autofillEnabled = true, String? autofillHint, + String? placeholderText, List? autofillHintsForFields, bool decimal = false, }) { @@ -2111,18 +2260,19 @@ Map createFlutterConfig( 'autocorrect': autocorrect, 'inputAction': inputAction ?? 'TextInputAction.done', 'textCapitalization': textCapitalization, - if (autofillHint != null) - 'autofill': createAutofillInfo(autofillHint, autofillHint), - if (autofillHintsForFields != null) + if (autofillEnabled) + 'autofill': createAutofillInfo(autofillHint, autofillHint ?? 'bogusId', placeholder: placeholderText), + if (autofillEnabled && autofillHintsForFields != null) 'fields': createFieldValues(autofillHintsForFields, autofillHintsForFields), }; } -Map createAutofillInfo(String hint, String uniqueId) => +Map createAutofillInfo(String? hint, String uniqueId, { String? placeholder }) => { 'uniqueIdentifier': uniqueId, - 'hints': [hint], + if (hint != null) 'hints': [hint], + if (placeholder != null) 'hintText': placeholder, 'editingValue': { 'text': 'Test', 'selectionBase': 0, diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index 80718cf3631f5..092a3f4588c41 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -465,18 +465,21 @@ public static Autofill fromJson(@NonNull JSONObject json) throws JSONException, NoSuchFieldException { final String uniqueIdentifier = json.getString("uniqueIdentifier"); final JSONArray hints = json.getJSONArray("hints"); + final String hintText = json.isNull("hintText") ? null : json.getString("hintText"); final JSONObject editingState = json.getJSONObject("editingValue"); - final String[] hintList = new String[hints.length()]; + final String[] autofillHints = new String[hints.length()]; - for (int i = 0; i < hintList.length; i++) { - hintList[i] = translateAutofillHint(hints.getString(i)); + for (int i = 0; i < hints.length(); i++) { + autofillHints[i] = translateAutofillHint(hints.getString(i)); } - return new Autofill(uniqueIdentifier, hintList, TextEditState.fromJson(editingState)); + return new Autofill( + uniqueIdentifier, autofillHints, hintText, TextEditState.fromJson(editingState)); } public final String uniqueIdentifier; public final String[] hints; public final TextEditState editState; + public final String hintText; @NonNull private static String translateAutofillHint(@NonNull String hint) { @@ -564,9 +567,11 @@ private static String translateAutofillHint(@NonNull String hint) { public Autofill( @NonNull String uniqueIdentifier, @NonNull String[] hints, + @Nullable String hintText, @NonNull TextEditState editingState) { this.uniqueIdentifier = uniqueIdentifier; this.hints = hints; + this.hintText = hintText; this.editState = editingState; } } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index b466728a0b8cd..a42838cac6ef5 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -658,7 +658,7 @@ public void didChangeEditingState( // // ### Keep the AFM updated // - // The autofill session connected to The AFM keeps a copy of the current state for each reported + // The autofill session connected to the AFM keeps a copy of the current state for each reported // field in "AutofillVirtualStructure" (instead of holding a reference to those fields), so the // AFM needs to be notified when text changes if the client was part of the // "AutofillVirtualStructure" previously reported to the AFM. This step is essential for @@ -761,6 +761,9 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags child.setAutofillHints(autofill.hints); child.setAutofillType(View.AUTOFILL_TYPE_TEXT); child.setVisibility(View.VISIBLE); + if (autofill.hintText != null) { + child.setHint(autofill.hintText); + } // For some autofill services, only visible input fields are eligible for autofill. // Reports the real size of the child if it's the current client, or 1x1 if we don't diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index eab8ce9d545cd..a323b37d1894e 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -666,6 +666,137 @@ public void showTextInput_textInputTypeNone() { } // -------- Start: Autofill Tests ------- + @Test + public void autofill_enabledByDefault() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + final TextInputChannel.Configuration.Autofill autofill = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {}, null, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config = + new TextInputChannel.Configuration( + false, + false, + true, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill, + null); + + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill, + new TextInputChannel.Configuration[] {config})); + + final ViewStructure viewStructure = mock(ViewStructure.class); + final ViewStructure[] children = {mock(ViewStructure.class), mock(ViewStructure.class)}; + + when(viewStructure.newChild(anyInt())) + .thenAnswer(invocation -> children[(int) invocation.getArgument(0)]); + + textInputPlugin.onProvideAutofillVirtualStructure(viewStructure, 0); + + verify(viewStructure).newChild(0); + + verify(children[0]).setAutofillId(any(), eq("1".hashCode())); + verify(children[0]).setAutofillHints(aryEq(new String[] {})); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); + } + + @Test + public void autofill_canBeDisabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + final TextInputChannel.Configuration.Autofill autofill = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {}, null, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config = + new TextInputChannel.Configuration( + false, + false, + true, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null); + + textInputPlugin.setTextInputClient(0, config); + + final ViewStructure viewStructure = mock(ViewStructure.class); + + textInputPlugin.onProvideAutofillVirtualStructure(viewStructure, 0); + + verify(viewStructure, times(0)).newChild(anyInt()); + } + + @Test + public void autofill_hintText() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + final TextInputChannel.Configuration.Autofill autofill = + new TextInputChannel.Configuration.Autofill( + "1", + new String[] {}, + "placeholder", + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config = + new TextInputChannel.Configuration( + false, + false, + true, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill, + null); + + textInputPlugin.setTextInputClient(0, config); + + final ViewStructure viewStructure = mock(ViewStructure.class); + final ViewStructure[] children = {mock(ViewStructure.class), mock(ViewStructure.class)}; + + when(viewStructure.newChild(anyInt())) + .thenAnswer(invocation -> children[(int) invocation.getArgument(0)]); + + textInputPlugin.onProvideAutofillVirtualStructure(viewStructure, 0); + verify(children[0]).setHint("placeholder"); + } + @Test public void autofill_onProvideVirtualViewStructure() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -678,11 +809,15 @@ public void autofill_onProvideVirtualViewStructure() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill1 = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + "1", + new String[] {"HINT1"}, + "placeholder1", + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( "2", new String[] {"HINT2", "EXTRA"}, + null, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration config1 = @@ -738,10 +873,12 @@ public void autofill_onProvideVirtualViewStructure() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); + verify(children[0]).setHint("placeholder1"); verify(children[1]).setAutofillId(any(), eq("2".hashCode())); verify(children[1]).setAutofillHints(aryEq(new String[] {"HINT2", "EXTRA"})); verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); + verify(children[1], times(0)).setHint(any()); } @Test @@ -756,7 +893,10 @@ public void autofill_onProvideVirtualViewStructure_single() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + "1", + new String[] {"HINT1"}, + "placeholder", + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Autofill should still work without AutofillGroup. textInputPlugin.setTextInputClient( @@ -785,6 +925,7 @@ public void autofill_onProvideVirtualViewStructure_single() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); + verify(children[0]).setHint("placeholder"); // Verifies that the child has a non-zero size. verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); } @@ -805,11 +946,15 @@ public void autofill_testLifeCycle() { // Set up an autofill scenario with 2 fields. final TextInputChannel.Configuration.Autofill autofill1 = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + "1", + new String[] {"HINT1"}, + "placeholder1", + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( "2", new String[] {"HINT2", "EXTRA"}, + null, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration config1 = @@ -930,11 +1075,13 @@ public void autofill_testAutofillUpdatesTheFramework() { new TextInputChannel.Configuration.Autofill( "1", new String[] {"HINT1"}, + null, new TextInputChannel.TextEditState("field 1", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( "2", new String[] {"HINT2", "EXTRA"}, + null, new TextInputChannel.TextEditState("field 2", 0, 0, -1, -1)); final TextInputChannel.Configuration config1 = @@ -1017,11 +1164,15 @@ public void autofill_testSetTextIpnutClientUpdatesSideFields() { // Set up an autofill scenario with 2 fields. final TextInputChannel.Configuration.Autofill autofill1 = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + "1", + new String[] {"HINT1"}, + "null", + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( "2", new String[] {"HINT2", "EXTRA"}, + "null", new TextInputChannel.TextEditState( "Unfocused fields need love like everything does", 0, 0, -1, -1)); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 3ca762561b932..555a4de0e7265 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -159,8 +159,9 @@ static UIReturnKeyType ToUIReturnKeyType(NSString* inputType) { } static UITextContentType ToUITextContentType(NSArray* hints) { - if (hints == nil || hints.count == 0) { - return @""; + if (!hints || hints.count == 0) { + // If no hints are specified, use the default content type nil. + return nil; } NSString* hint = hints[0]; @@ -286,18 +287,55 @@ static UITextContentType ToUITextContentType(NSArray* hints) { return [dictionary[kSecureTextEntry] boolValue] ? @"password" : nil; } -// There're 2 types of autofills on native iOS: -// - Regular autofill, includes contact information autofill and -// one-time-code autofill, takes place in the form of predictive -// text in the quick type bar. This type of autofill does not save -// user input. +// # Autofill Implementation Notes: +// +// Currently there're 2 types of autofills on iOS: +// - Regular autofill, including contact information and one-time-code, +// takes place in the form of predictive text in the quick type bar. +// This type of autofill does not save user input, and the keyboard +// currently only populates the focused field when a predictive text entry +// is selected by the user. +// // - Password autofill, includes automatic strong password and regular // password autofill. The former happens automatically when a -// "new password" field is detected, and only that password field -// will be populated. The latter appears in the quick type bar when -// an eligible input field becomes the first responder, and may +// "new password" field is detected and focused, and only that password +// field will be populated. The latter appears in the quick type bar when +// an eligible input field (which either has a UITextContentTypePassword +// contentType, or is a secure text entry) becomes the first responder, and may // fill both the username and the password fields. iOS will attempt -// to save user input for both kinds of password fields. +// to save user input for both kinds of password fields. It's relatively +// tricky to deal with password autofill since it can autofill more than one +// field at a time and may employ heuristics based on what other text fields +// are in the same view controller. +// +// When a flutter text field is focused, and autofill is not explicitly disabled +// for it ("autofillable"), the framework collects its attributes and checks if +// it's in an AutofillGroup, and collects the attributes of other autofillable +// text fields in the same AutofillGroup if so. The attributes are sent to the +// text input plugin via a "TextInput.setClient" platform channel message. If +// autofill is disabled for a text field, its "autofill" field will be nil in +// the configuration json. +// +// The text input plugin then tries to determine which kind of autofill the text +// field needs. If the AutofillGroup the text field belongs to contains an +// autofillable text field that's password related, this text 's autofill type +// will be FlutterAutofillTypePassword. If autofill is disabled for a text field, +// then its type will be FlutterAutofillTypeNone. Otherwise the text field will +// have an autofill type of FlutterAutofillTypeRegular. +// +// The text input plugin creates a new UIView for every FlutterAutofillTypeNone +// text field. The UIView instance is never reused for other flutter text fields +// since the software keyboard often uses the identity of a UIView to distinguish +// different views and provides the same predictive text suggestions or restore +// the composing region if a UIView is reused for a different flutter text field. +// +// The text input plugin creates a new "autofill context" if the text field has +// the type of FlutterAutofillTypePassword, to represent the AutofillGroup of +// the text field, and creates one FlutterTextInputView for every text field in +// the AutofillGroup. +// +// The text input plugin will try to reuse a UIView if a flutter text field's +// type is FlutterAutofillTypeRegular, and has the same autofill id. typedef NS_ENUM(NSInteger, FlutterAutofillType) { // The field does not have autofillable content. Additionally if // the field is currently in the autofill context, it will be @@ -309,13 +347,15 @@ typedef NS_ENUM(NSInteger, FlutterAutofillType) { static BOOL isFieldPasswordRelated(NSDictionary* configuration) { if (@available(iOS 10.0, *)) { + // Autofill is explicitly disabled if the id isn't present. + if (!autofillIdFromDictionary(configuration)) { + return NO; + } + BOOL isSecureTextEntry = [configuration[kSecureTextEntry] boolValue]; if (isSecureTextEntry) return YES; - if (!autofillIdFromDictionary(configuration)) { - return NO; - } NSDictionary* autofill = configuration[kAutofillProperties]; UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]); @@ -349,7 +389,8 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { if (@available(iOS 10.0, *)) { NSDictionary* autofill = configuration[kAutofillProperties]; UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]); - return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular; + return !autofill || [contentType isEqualToString:@""] ? FlutterAutofillTypeNone + : FlutterAutofillTypeRegular; } return FlutterAutofillTypeNone; @@ -1659,13 +1700,11 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur } // Creates and shows an input field that is not password related and has no autofill -// hints. This method returns a new FlutterTextInputView instance when called, since +// info. This method returns a new FlutterTextInputView instance when called, since // UIKit uses the identity of `UITextInput` instances (or the identity of the input // views) to decide whether the IME's internal states should be reset. See: // https://github.com/flutter/flutter/issues/79031 . - (FlutterTextInputView*)createInputViewWith:(NSDictionary*)configuration { - // It's possible that the configuration of this non-autofillable input view has - // an autofill configuration without hints. If it does, remove it from the context. NSString* autofillId = autofillIdFromDictionary(configuration); if (autofillId) { [_autofillContext removeObjectForKey:autofillId]; @@ -1715,7 +1754,7 @@ - (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields : [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword]; } else { - // Mark for deletion; + // Mark for deletion. [_autofillContext removeObjectForKey:autofillId]; } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 7b93a5f582cb5..b96325b5e9a29 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -633,6 +633,28 @@ - (void)commitAutofillContextAndVerify { #pragma mark - Autofill - Tests +- (void)testDisablingAutofillOnInputClient { + NSDictionary* config = self.mutableTemplateCopy; + [config setValue:@"YES" forKey:@"obscureText"]; + + [self setClientId:123 configuration:config]; + + FlutterTextInputView* inputView = self.installedInputViews[0]; + XCTAssertEqualObjects(inputView.textContentType, @""); +} + +- (void)testAutofillEnabledByDefault { + NSDictionary* config = self.mutableTemplateCopy; + [config setValue:@"NO" forKey:@"obscureText"]; + [config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}} + forKey:@"autofill"]; + + [self setClientId:123 configuration:config]; + + FlutterTextInputView* inputView = self.installedInputViews[0]; + XCTAssertNil(inputView.textContentType); +} + - (void)testAutofillContext { NSMutableDictionary* field1 = self.mutableTemplateCopy;