Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ class EngineAutofillForm {

final elements = <String, DomHTMLElement>{};

final Map<String, String> _lastSentAutofillText = <String, String>{};
Comment thread
flutter-zl marked this conversation as resolved.

final Map<String, String> _lastFrameworkText = <String, String>{};

final Map<String, FieldItem> items;

/// Identifier for the form.
Expand Down Expand Up @@ -318,6 +322,14 @@ class EngineAutofillForm {
// If the form already has a dormant DOM element, let's use it instead of creating a new one.
formElement = existingForm.formElement;
elements.addAll(existingForm.elements);
// Carry over the per-field tracking so the reused form remembers the
// last framework value and the last value forwarded for each field.
// Each text input connection builds a new form instance, so without
// this the tracking would reset on every focus change and the form
// could not tell a programmatic framework update apart from a browser
// autofill.
_lastFrameworkText.addAll(existingForm._lastFrameworkText);
_lastSentAutofillText.addAll(existingForm._lastSentAutofillText);
} else {
formElement = _createFormElementAndFields(focusedElement, focusedAutofill);
_insertEditingElementInView(formElement!, viewId);
Expand Down Expand Up @@ -416,8 +428,33 @@ class EngineAutofillForm {
final AutofillInfo autofill = items[key]!.autofillInfo;
// Focused elements are updated directly through `setEditingState`.
if (key != focusedElementId) {
// Non-focused elements do not have selection, and applying selection on them may cause them
// to gain focus unexpectedly.
// A non-focused field's DOM value and the framework's stored value can
// diverge in two ways. When the browser autofills the field while the
// form is dormant, the DOM holds a value the framework has not seen yet,
// so it must be forwarded and kept. Otherwise the framework is the source
// of truth, either it just changed (a programmatic update) or it is
// already in sync. The last framework value per field tells them apart:
// an autofill is an unchanged framework value next to a changed DOM value.
final domEditingState = EditingState.fromDomElement(element);
final String frameworkText = autofill.editingState.text;
final String lastFrameworkText =
_lastFrameworkText[autofill.uniqueIdentifier] ?? frameworkText;
_lastFrameworkText[autofill.uniqueIdentifier] = frameworkText;

final frameworkUnchanged = frameworkText == lastFrameworkText;
if (frameworkUnchanged &&
domEditingState.text.isNotEmpty &&
domEditingState.text != frameworkText) {
// The browser autofilled this field. Forward the new value and keep it
// instead of clearing it.
if (domEditingState.text != _lastSentAutofillText[autofill.uniqueIdentifier]) {
_sendAutofillEditingState(autofill.uniqueIdentifier, domEditingState);
}
continue;
}
Comment thread
flutter-zl marked this conversation as resolved.
// The framework wins: it changed (a programmatic update) or is in sync.
// Non-focused elements do not have selection, and applying selection on
// them may cause them to gain focus unexpectedly.
autofill.editingState.applyTextToDomElement(element);
}
}
Expand Down Expand Up @@ -467,6 +504,7 @@ class EngineAutofillForm {

/// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework.
void _sendAutofillEditingState(String tag, EditingState editingState) {
_lastSentAutofillText[tag] = editingState.text;
Comment thread
flutter-zl marked this conversation as resolved.
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
Expand Down
131 changes: 131 additions & 0 deletions engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2617,6 +2617,63 @@ Future<void> testMain() async {
hideKeyboard();
});

test('multiTextField Autofill preserves changed dormant values on wake', () async {
final Map<String, dynamic> flutterMultiAutofillElementConfig = createFlutterConfig(
'text',
autofillHint: 'username',
autofillHintsForFields: <String>['username', 'current-password'],
);
final setClient = MethodCall('TextInput.setClient', <dynamic>[
123,
flutterMultiAutofillElementConfig,
]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));

const show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(
150,
50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList(),
);
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

var formElement = defaultTextEditingRoot.querySelector('form')! as DomHTMLFormElement;
final passwordElement = formElement.childNodes.toList()[1] as DomHTMLInputElement;

const clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
passwordElement.value = 'secret-password';
spy.messages.clear();

sendFrameworkMessage(codec.encodeMethodCall(setClient));
sendFrameworkMessage(codec.encodeMethodCall(show));
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

formElement = defaultTextEditingRoot.querySelector('form')! as DomHTMLFormElement;
final restoredPasswordElement = formElement.childNodes.toList()[1] as DomHTMLInputElement;
expect(restoredPasswordElement.value, 'secret-password');
final Iterable<PlatformMessage> autofillMessages = spy.messages.where(
(PlatformMessage message) =>
message.methodName == 'TextInputClient.updateEditingStateWithTag',
);
expect(autofillMessages, hasLength(1));
expect(autofillMessages.single.channel, 'flutter/textinput');
expect(autofillMessages.single.methodArguments, <dynamic>[
0,
<String, dynamic>{
'current-password': <String, dynamic>{
'text': 'secret-password',
'selectionBase': 15,
'selectionExtent': 15,
'composingBase': -1,
'composingExtent': -1,
},
},
]);
});

test('Multi-line mode also works', () async {
final setClient = MethodCall('TextInput.setClient', <dynamic>[123, flutterMultilineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
Expand Down Expand Up @@ -3220,6 +3277,80 @@ Future<void> testMain() async {
});

group('EngineAutofillForm', () {
test('applies a programmatic change to a non-focused field instead of reverting it', () {
// A programmatic framework update to a non-focused autofill field must win
// over the stale DOM value, and must not be mistaken for a browser
// autofill. See the review on https://github.com/flutter/flutter/pull/187459.
Map<String, Object?> field(String hint, String id, String text) => <String, Object?>{
'inputType': <String, Object?>{
'name': 'TextInputType.text',
'signed': null,
'decimal': null,
},
'textCapitalization': 'TextCapitalization.none',
'autofill': <String, dynamic>{
'uniqueIdentifier': id,
'hints': <String>[hint],
'editingValue': <String, dynamic>{
'text': text,
'selectionBase': 0,
'selectionExtent': 0,
'selectionAffinity': 'TextAffinity.downstream',
'selectionIsDirectional': false,
'composingBase': -1,
'composingExtent': -1,
},
},
};

final spy = PlatformMessagesSpy();
spy.setUp();
try {
// Form 1: the non-focused password field holds 'pw-v1'.
final fields1 = <Map<String, Object?>>[
field('username', 'field1', 'user'),
field('password', 'field2', 'pw-v1'),
];
final focusedMap1 = fields1.first['autofill']! as Map<String, Object?>;
final EngineAutofillForm form1 = EngineAutofillForm.fromFrameworkMessage(
kImplicitViewId,
focusedMap1,
fields1,
)!;
form1.wakeUp(createDomHTMLInputElement(), AutofillInfo.fromFrameworkMessage(focusedMap1));
final passwordElement = form1.elements['field2']! as DomHTMLInputElement;
expect(passwordElement.value, 'pw-v1');
form1.goDormant();

// The app programmatically changes the password to 'pw-v2'. A new config
// arrives as a new form that reuses the dormant one.
final fields2 = <Map<String, Object?>>[
field('username', 'field1', 'user'),
field('password', 'field2', 'pw-v2'),
];
final focusedMap2 = fields2.first['autofill']! as Map<String, Object?>;
final EngineAutofillForm form2 = EngineAutofillForm.fromFrameworkMessage(
kImplicitViewId,
focusedMap2,
fields2,
)!;
spy.messages.clear();
form2.wakeUp(createDomHTMLInputElement(), AutofillInfo.fromFrameworkMessage(focusedMap2));

// The DOM must reflect the app's new value, not revert to 'pw-v1', and we
// must not forward the stale value as if the browser had autofilled it.
final reusedPassword = form2.elements['field2']! as DomHTMLInputElement;
expect(reusedPassword.value, 'pw-v2');
expect(
spy.messages.where((m) => m.methodName == 'TextInputClient.updateEditingStateWithTag'),
isEmpty,
);
} finally {
spy.tearDown();
clearForms();
}
});

test('validate multi element form', () {
final List<Map<String, Object?>> fields = createFieldValues(
<String>['username', 'password', 'newPassword'],
Expand Down
Loading