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 b206e5aa2e36aa18e8e25f4852c04aae0c26e2a1..8cb778daf11677667dd70a3c0f072af9d7cff236 100644
--- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java
+++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java
@@ -112,6 +112,10 @@ public class TextInputChannel {
textInputMethodHandler.clearClient();
result.success(null);
break;
+ case "TextInput.finishAutofillContext":
+ textInputMethodHandler.finishAutofillContext((boolean) args);
+ result.success(null);
+ break;
default:
result.notImplemented();
break;
@@ -284,6 +288,18 @@ public class TextInputChannel {
*/
void requestAutofill();
+ /**
+ * Requests that the {@link AutofillManager} cancel or commit the current autofill context.
+ *
+ *
The method calls {@link android.view.autofill.AutofillManager#commit()} when {@code
+ * shouldSave} is true, and calls {@link android.view.autofill.AutofillManager#cancel()}
+ * otherwise.
+ *
+ * @param shouldSave whether the active autofill service should save the current user input for
+ * future use.
+ */
+ void finishAutofillContext(boolean shouldSave);
+
// TODO(mattcarroll): javadoc
void setClient(int textInputClientId, @NonNull Configuration configuration);
diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
index 698a44cccfd739367ed3c801370ec671960ee6e2..1aea8fbb279e3b96478ffa7eb8ddf02f57da04df 100644
--- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
+++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
@@ -82,6 +82,18 @@ public class TextInputPlugin {
notifyViewEntered();
}
+ @Override
+ public void finishAutofillContext(boolean shouldSave) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null) {
+ return;
+ }
+ if (shouldSave) {
+ afm.commit();
+ } else {
+ afm.cancel();
+ }
+ }
+
@Override
public void setClient(
int textInputClientId, TextInputChannel.Configuration configuration) {
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 f12daf40c13e385a725a4768e666afb662d7f7fa..bd4eb6f4b32ecc5726ed19587aca7e1bb2373721 100644
--- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
+++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
@@ -70,6 +70,14 @@ public class TextInputPluginTest {
}
}
+ private static void sendToBinaryMessageHandler(
+ BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) {
+ MethodCall methodCall = new MethodCall(method, args);
+ ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall);
+ binaryMessageHandler.onMessage(
+ (ByteBuffer) encodedMethodCall.flip(), mock(BinaryMessenger.BinaryReply.class));
+ }
+
@Test
public void textInputPlugin_RequestsReattachOnCreation() throws JSONException {
// Initialize a general TextInputPlugin.
@@ -531,6 +539,33 @@ public class TextInputPluginTest {
verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0));
}
+ @Test
+ public void respondsToInputChannelMessages() {
+ ArgumentCaptor binaryMessageHandlerCaptor =
+ ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class);
+ DartExecutor mockBinaryMessenger = mock(DartExecutor.class);
+ TextInputChannel.TextInputMethodHandler mockHandler =
+ mock(TextInputChannel.TextInputMethodHandler.class);
+ TextInputChannel textInputChannel = new TextInputChannel(mockBinaryMessenger);
+
+ textInputChannel.setTextInputMethodHandler(mockHandler);
+
+ verify(mockBinaryMessenger, times(1))
+ .setMessageHandler(any(String.class), binaryMessageHandlerCaptor.capture());
+
+ BinaryMessenger.BinaryMessageHandler binaryMessageHandler =
+ binaryMessageHandlerCaptor.getValue();
+
+ sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.requestAutofill", null);
+ verify(mockHandler, times(1)).requestAutofill();
+
+ sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.finishAutofillContext", true);
+ verify(mockHandler, times(1)).finishAutofillContext(true);
+
+ sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.finishAutofillContext", false);
+ verify(mockHandler, times(1)).finishAutofillContext(false);
+ }
+
@Implements(InputMethodManager.class)
public static class TestImm extends ShadowInputMethodManager {
private InputMethodSubtype currentInputMethodSubtype;
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
index 7130469ee7bd44ef645ddc7daea065cd62b42c8c..a135c4fcc7f194257c64a3c307dd7be945d1464f 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
@@ -11,6 +11,26 @@
static const char _kTextAffinityDownstream[] = "TextAffinity.downstream";
static const char _kTextAffinityUpstream[] = "TextAffinity.upstream";
+#pragma mark - TextInputConfiguration Field Names
+static NSString* const kSecureTextEntry = @"obscureText";
+static NSString* const kKeyboardType = @"inputType";
+static NSString* const kKeyboardAppearance = @"keyboardAppearance";
+static NSString* const kInputAction = @"inputAction";
+
+static NSString* const kSmartDashesType = @"smartDashesType";
+static NSString* const kSmartQuotesType = @"smartQuotesType";
+
+static NSString* const kAssociatedAutofillFields = @"fields";
+
+// TextInputConfiguration.autofill and sub-field names
+static NSString* const kAutofillProperties = @"autofill";
+static NSString* const kAutofillId = @"uniqueIdentifier";
+static NSString* const kAutofillEditingValue = @"editingValue";
+static NSString* const kAutofillHints = @"hints";
+
+static NSString* const kAutocorrectionType = @"autocorrect";
+
+#pragma mark - Static Functions
static UIKeyboardType ToUIKeyboardType(NSDictionary* type) {
NSString* inputType = type[@"name"];
if ([inputType isEqualToString:@"TextInputType.address"])
@@ -209,9 +229,86 @@ static UITextContentType ToUITextContentType(NSArray* hints) {
return hints[0];
}
-static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
- NSDictionary* autofill = dictionary[@"autofill"];
- return autofill == nil ? nil : autofill[@"uniqueIdentifier"];
+// Retrieves the autofillId from an input field's configuration. Returns
+// nil if the field is nil and the input field is not a password field.
+static NSString* autofillIdFromDictionary(NSDictionary* dictionary) {
+ NSDictionary* autofill = dictionary[kAutofillProperties];
+ if (autofill) {
+ return autofill[kAutofillId];
+ }
+
+ // When autofill is nil, the field may still need an autofill id
+ // if the field is for password.
+ 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.
+// - 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
+// fill both the username and the password fields. iOS will attempt
+// to save user input for both kinds of password fields.
+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
+ // removed from the context without triggering autofill save.
+ FlutterAutofillTypeNone,
+ FlutterAutofillTypeRegular,
+ FlutterAutofillTypePassword,
+};
+
+static BOOL isFieldPasswordRelated(NSDictionary* configuration) {
+ if (@available(iOS 10.0, *)) {
+ BOOL isSecureTextEntry = [configuration[kSecureTextEntry] boolValue];
+ if (isSecureTextEntry)
+ return YES;
+
+ if (!autofillIdFromDictionary(configuration)) {
+ return NO;
+ }
+ NSDictionary* autofill = configuration[kAutofillProperties];
+ UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]);
+
+ if (@available(iOS 11.0, *)) {
+ if ([contentType isEqualToString:UITextContentTypePassword] ||
+ [contentType isEqualToString:UITextContentTypeUsername]) {
+ return YES;
+ }
+ }
+
+ if (@available(iOS 12.0, *)) {
+ if ([contentType isEqualToString:UITextContentTypeNewPassword]) {
+ return YES;
+ }
+ }
+ }
+ return NO;
+}
+
+static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
+ for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
+ if (isFieldPasswordRelated(field)) {
+ return FlutterAutofillTypePassword;
+ }
+ }
+
+ if (isFieldPasswordRelated(configuration)) {
+ return FlutterAutofillTypePassword;
+ }
+
+ if (@available(iOS 10.0, *)) {
+ NSDictionary* autofill = configuration[kAutofillProperties];
+ UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]);
+ return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular;
+ }
+
+ return FlutterAutofillTypeNone;
}
#pragma mark - FlutterTextPosition
@@ -269,8 +366,54 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
}
@end
+// A FlutterTextInputView that masquerades as a UITextField, and forwards
+// selectors it can't respond to to a shared UITextField instance.
+//
+// Relevant API docs claim that password autofill supports any custom view
+// that adopts the UITextInput protocol, automatic strong password seems to
+// currently only support UITextFields, and password saving only supports
+// UITextFields and UITextViews, as of iOS 13.5.
+@interface FlutterSecureTextInputView : FlutterTextInputView
+@property(nonatomic, strong, readonly) UITextField* textField;
+@end
+
+@implementation FlutterSecureTextInputView {
+ UITextField* _textField;
+}
+
+- (void)dealloc {
+ [_textField release];
+ [super dealloc];
+}
+
+- (UITextField*)textField {
+ if (_textField == nil) {
+ _textField = [[[UITextField alloc] init] autorelease];
+ }
+ return _textField;
+}
+
+- (BOOL)isKindOfClass:(Class)aClass {
+ return [super isKindOfClass:aClass] || (aClass == [UITextField class]);
+}
+
+- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector {
+ NSMethodSignature* signature = [super methodSignatureForSelector:aSelector];
+ if (!signature) {
+ signature = [self.textField methodSignatureForSelector:aSelector];
+ }
+ return signature;
+}
+
+- (void)forwardInvocation:(NSInvocation*)anInvocation {
+ [anInvocation invokeWithTarget:self.textField];
+}
+
+@end
+
@interface FlutterTextInputView ()
@property(nonatomic, copy) NSString* autofillId;
+@property(nonatomic) BOOL isVisibleToAutofill;
@end
@implementation FlutterTextInputView {
@@ -311,6 +454,59 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
return self;
}
+- (void)configureWithDictionary:(NSDictionary*)configuration {
+ NSDictionary* inputType = configuration[kKeyboardType];
+ NSString* keyboardAppearance = configuration[kKeyboardAppearance];
+ NSDictionary* autofill = configuration[kAutofillProperties];
+
+ self.secureTextEntry = [configuration[kSecureTextEntry] boolValue];
+ self.keyboardType = ToUIKeyboardType(inputType);
+ self.keyboardType = UIKeyboardTypeNamePhonePad;
+ self.returnKeyType = ToUIReturnKeyType(configuration[kInputAction]);
+ self.autocapitalizationType = ToUITextAutoCapitalizationType(configuration);
+
+ if (@available(iOS 11.0, *)) {
+ NSString* smartDashesType = configuration[kSmartDashesType];
+ // This index comes from the SmartDashesType enum in the framework.
+ bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"];
+ self.smartDashesType =
+ smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes;
+ NSString* smartQuotesType = configuration[kSmartQuotesType];
+ // This index comes from the SmartQuotesType enum in the framework.
+ bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"];
+ self.smartQuotesType =
+ smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes;
+ }
+ if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) {
+ self.keyboardAppearance = UIKeyboardAppearanceDark;
+ } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) {
+ self.keyboardAppearance = UIKeyboardAppearanceLight;
+ } else {
+ self.keyboardAppearance = UIKeyboardAppearanceDefault;
+ }
+ NSString* autocorrect = configuration[kAutocorrectionType];
+ self.autocorrectionType = autocorrect && ![autocorrect boolValue]
+ ? UITextAutocorrectionTypeNo
+ : UITextAutocorrectionTypeDefault;
+ if (@available(iOS 10.0, *)) {
+ self.autofillId = autofillIdFromDictionary(configuration);
+ if (autofill == nil) {
+ self.textContentType = @"";
+ } else {
+ self.textContentType = ToUITextContentType(autofill[kAutofillHints]);
+ [self setTextInputState:autofill[kAutofillEditingValue]];
+ NSAssert(_autofillId, @"The autofill configuration must contain an autofill id");
+ }
+ // The input field needs to be visible for the system autofill
+ // to find it.
+ self.isVisibleToAutofill = autofill || _secureTextEntry;
+ }
+}
+
+- (UITextContentType)textContentType {
+ return _textContentType;
+}
+
- (void)dealloc {
[_text release];
[_markedText release];
@@ -392,12 +588,26 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
return NSMakeRange(start, length);
}
+- (BOOL)isVisibleToAutofill {
+ return self.frame.size.width > 0 && self.frame.size.height > 0;
+}
+
+// An input view is generally ignored by password autofill attempts, if it's
+// not the first responder and is zero-sized. For input fields that are in the
+// autofill context but do not belong to the current autofill group, setting
+// their frames to CGRectZero prevents ios autofill from taking them into
+// account.
+- (void)setIsVisibleToAutofill:(BOOL)isVisibleToAutofill {
+ self.frame = isVisibleToAutofill ? CGRectMake(0, 0, 1, 1) : CGRectZero;
+}
+
#pragma mark - UIResponder Overrides
- (BOOL)canBecomeFirstResponder {
// Only the currently focused input field can
// become the first responder. This prevents iOS
- // from changing focus by itself.
+ // from changing focus by itself (the framework
+ // focus will be out of sync if that happens).
return _textInputClient != 0;
}
@@ -701,7 +911,7 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
// physical keyboard.
- (CGRect)firstRectForRange:(UITextRange*)range {
- // multi-stage text is handled somewhere else.
+ // multi-stage text is handled in the framework.
if (_markedTextRange != nil) {
return CGRectZero;
}
@@ -845,9 +1055,11 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
@end
@interface FlutterTextInputPlugin ()
-@property(nonatomic, retain) FlutterTextInputView* nonAutofillInputView;
-@property(nonatomic, retain) FlutterTextInputView* nonAutofillSecureInputView;
-@property(nonatomic, retain) NSMutableArray* inputViews;
+@property(nonatomic, strong) FlutterTextInputView* reusableInputView;
+
+// The current password-autofillable input fields that have yet to be saved.
+@property(nonatomic, readonly)
+ NSMutableDictionary* autofillContext;
@property(nonatomic, assign) FlutterTextInputView* activeView;
@end
@@ -859,13 +1071,10 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
self = [super init];
if (self) {
- _nonAutofillInputView = [[FlutterTextInputView alloc] init];
- _nonAutofillInputView.secureTextEntry = NO;
- _nonAutofillSecureInputView = [[FlutterTextInputView alloc] init];
- _nonAutofillSecureInputView.secureTextEntry = YES;
- _inputViews = [[NSMutableArray alloc] init];
-
- _activeView = _nonAutofillInputView;
+ _reusableInputView = [[FlutterTextInputView alloc] init];
+ _reusableInputView.secureTextEntry = NO;
+ _autofillContext = [[NSMutableDictionary alloc] init];
+ _activeView = _reusableInputView;
}
return self;
@@ -873,9 +1082,8 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
- (void)dealloc {
[self hideTextInput];
- [_nonAutofillInputView release];
- [_nonAutofillSecureInputView release];
- [_inputViews release];
+ [_reusableInputView release];
+ [_autofillContext release];
[super dealloc];
}
@@ -902,21 +1110,17 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
} else if ([method isEqualToString:@"TextInput.clearClient"]) {
[self clearTextInputClient];
result(nil);
+ } else if ([method isEqualToString:@"TextInput.finishAutofillContext"]) {
+ [self triggerAutofillSave:[args boolValue]];
+ result(nil);
} else {
result(FlutterMethodNotImplemented);
}
}
- (void)showTextInput {
- UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
- NSAssert(keyWindow != nullptr,
- @"The application must have a key window since the keyboard client "
- @"must be part of the responder chain to function");
_activeView.textInputDelegate = _textInputDelegate;
-
- if (_activeView.window != keyWindow) {
- [keyWindow addSubview:_activeView];
- }
+ [self addToKeyWindowIfNeeded:_activeView];
[_activeView becomeFirstResponder];
}
@@ -924,102 +1128,187 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
[_activeView resignFirstResponder];
}
+- (void)triggerAutofillSave:(BOOL)saveEntries {
+ [self hideTextInput];
+
+ if (saveEntries) {
+ // Make all the input fields in the autofill context visible,
+ // then remove them to trigger autofill save.
+ [self cleanUpViewHierarchy:YES clearText:YES];
+ [_autofillContext removeAllObjects];
+ [self changeInputViewsAutofillVisibility:YES];
+ } else {
+ [_autofillContext removeAllObjects];
+ }
+
+ [self cleanUpViewHierarchy:YES clearText:!saveEntries];
+ [self addToKeyWindowIfNeeded:_activeView];
+}
+
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
- UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
- NSArray* fields = configuration[@"fields"];
- NSString* clientUniqueId = uniqueIdFromDictionary(configuration);
- bool isSecureTextEntry = [configuration[@"obscureText"] boolValue];
+ // Hide all input views from autofill, only make those in the new configuration visible
+ // to autofill.
+ [self changeInputViewsAutofillVisibility:NO];
+ switch (autofillTypeOf(configuration)) {
+ case FlutterAutofillTypeNone:
+ _activeView = [self updateAndShowReusableInputView:configuration];
+ break;
+ case FlutterAutofillTypeRegular:
+ // If the group does not involve password autofill, only install the
+ // input view that's being focused.
+ _activeView = [self updateAndShowAutofillViews:nil
+ focusedField:configuration
+ isPasswordRelated:NO];
+ break;
+ case FlutterAutofillTypePassword:
+ _activeView = [self updateAndShowAutofillViews:configuration[kAssociatedAutofillFields]
+ focusedField:configuration
+ isPasswordRelated:YES];
+ break;
+ }
- if (fields == nil) {
- _activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView;
- [FlutterTextInputPlugin setupInputView:_activeView withConfiguration:configuration];
+ // Clean up views that should no longer be in the view hierarchy according to the
+ // updated autofill context.
+ [self cleanUpViewHierarchy:NO clearText:YES];
+ [_activeView setTextInputClient:client];
+ [_activeView reloadInputViews];
+}
- if (_activeView.window != keyWindow) {
- [keyWindow addSubview:_activeView];
- }
- } else {
- NSAssert(clientUniqueId != nil, @"The client's unique id can't be null");
- for (FlutterTextInputView* view in _inputViews) {
- [view removeFromSuperview];
- }
+// Updates and shows an input field that is not password related and has no autofill
+// hints. This method re-configures and reuses an existing instance of input field
+// instead of creating a new one.
+// Also updates the current autofill context.
+- (FlutterTextInputView*)updateAndShowReusableInputView:(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];
+ }
- for (UIView* view in keyWindow.subviews) {
- if ([view isKindOfClass:[FlutterTextInputView class]]) {
- [view removeFromSuperview];
- }
+ [_reusableInputView configureWithDictionary:configuration];
+ [self addToKeyWindowIfNeeded:_reusableInputView];
+ _reusableInputView.textInputDelegate = _textInputDelegate;
+
+ for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
+ NSString* autofillId = autofillIdFromDictionary(field);
+ if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) {
+ [_autofillContext removeObjectForKey:autofillId];
}
+ }
+ return _reusableInputView;
+}
- [_inputViews removeAllObjects];
+- (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields
+ focusedField:(NSDictionary*)focusedField
+ isPasswordRelated:(BOOL)isPassword {
+ FlutterTextInputView* focused = nil;
+ NSString* focusedId = autofillIdFromDictionary(focusedField);
+ NSAssert(focusedId, @"autofillId must not be null for the focused field: %@", focusedField);
- for (NSDictionary* field in fields) {
- FlutterTextInputView* newInputView = [[[FlutterTextInputView alloc] init] autorelease];
- newInputView.textInputDelegate = _textInputDelegate;
- [_inputViews addObject:newInputView];
+ if (!fields) {
+ // DO NOT push the current autofillable input fields to the context even
+ // if it's password-related, because it is not in an autofill group.
+ focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:isPassword];
+ [_autofillContext removeObjectForKey:focusedId];
+ }
- NSString* autofillId = uniqueIdFromDictionary(field);
- newInputView.autofillId = autofillId;
+ for (NSDictionary* field in fields) {
+ NSString* autofillId = autofillIdFromDictionary(field);
+ NSAssert(autofillId, @"autofillId must not be null for field: %@", field);
- if ([clientUniqueId isEqualToString:autofillId]) {
- _activeView = newInputView;
- }
+ BOOL hasHints = autofillTypeOf(field) != FlutterAutofillTypeNone;
+ BOOL isFocused = [focusedId isEqualToString:autofillId];
- [FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field];
- [keyWindow addSubview:newInputView];
+ if (isFocused) {
+ focused = [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword];
+ }
+
+ if (hasHints) {
+ // Push the current input field to the context if it has hints.
+ _autofillContext[autofillId] = isFocused ? focused
+ : [self getOrCreateAutofillableView:field
+ isPasswordAutofill:isPassword];
+ } else {
+ // Mark for deletion;
+ [_autofillContext removeObjectForKey:autofillId];
}
}
- [_activeView setTextInputClient:client];
- [_activeView reloadInputViews];
-}
+ NSAssert(focused, @"The current focused input view must not be nil.");
+ return focused;
+}
+
+// Returns a new non-reusable input view (and put it into the view hierarchy), or get the
+// view from the current autofill context, if an input view with the same autofill id
+// already exists in the context.
+// This is generally used for input fields that are autofillable (UIKit tracks these veiws
+// for autofill purposes so they should not be reused for a different type of views).
+- (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field
+ isPasswordAutofill:(BOOL)needsPasswordAutofill {
+ NSString* autofillId = autofillIdFromDictionary(field);
+ FlutterTextInputView* inputView = _autofillContext[autofillId];
+ if (!inputView) {
+ inputView =
+ needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc];
+ inputView = [[inputView init] autorelease];
+ [self addToKeyWindowIfNeeded:inputView];
+ }
-+ (void)setupInputView:(FlutterTextInputView*)inputView
- withConfiguration:(NSDictionary*)configuration {
- NSDictionary* inputType = configuration[@"inputType"];
- NSString* keyboardAppearance = configuration[@"keyboardAppearance"];
- NSDictionary* autofill = configuration[@"autofill"];
+ inputView.textInputDelegate = _textInputDelegate;
+ [inputView configureWithDictionary:field];
+ return inputView;
+}
- inputView.secureTextEntry = [configuration[@"obscureText"] boolValue];
- inputView.keyboardType = ToUIKeyboardType(inputType);
- inputView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]);
- inputView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration);
+// Removes every installed input field, unless it's in the current autofill
+// context. May remove the active view too if includeActiveView is YES.
+// When clearText is YES, the text on the input fields will be set to empty before
+// they are removed from the view hierarchy, to avoid autofill save .
+- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
+ UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
+ NSAssert(keyWindow != nullptr,
+ @"The application must have a key window since the keyboard client "
+ @"must be part of the responder chain to function");
- if (@available(iOS 11.0, *)) {
- NSString* smartDashesType = configuration[@"smartDashesType"];
- // This index comes from the SmartDashesType enum in the framework.
- bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"];
- inputView.smartDashesType =
- smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes;
- NSString* smartQuotesType = configuration[@"smartQuotesType"];
- // This index comes from the SmartQuotesType enum in the framework.
- bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"];
- inputView.smartQuotesType =
- smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes;
- }
- if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) {
- inputView.keyboardAppearance = UIKeyboardAppearanceDark;
- } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) {
- inputView.keyboardAppearance = UIKeyboardAppearanceLight;
- } else {
- inputView.keyboardAppearance = UIKeyboardAppearanceDefault;
+ for (UIView* view in keyWindow.subviews) {
+ if ([view isKindOfClass:[FlutterTextInputView class]] &&
+ (includeActiveView || view != _activeView)) {
+ FlutterTextInputView* inputView = (FlutterTextInputView*)view;
+ if (_autofillContext[inputView.autofillId] != view) {
+ if (clearText) {
+ inputView.text.string = @"";
+ }
+ [view removeFromSuperview];
+ }
+ }
}
- NSString* autocorrect = configuration[@"autocorrect"];
- inputView.autocorrectionType = autocorrect && ![autocorrect boolValue]
- ? UITextAutocorrectionTypeNo
- : UITextAutocorrectionTypeDefault;
- if (@available(iOS 10.0, *)) {
- if (autofill == nil) {
- inputView.textContentType = @"";
- } else {
- inputView.textContentType = ToUITextContentType(autofill[@"hints"]);
- [inputView setTextInputState:autofill[@"editingValue"]];
- // An input field needs to be visible in order to get
- // autofilled when it's not the one that triggered
- // autofill.
- inputView.frame = CGRectMake(0, 0, 1, 1);
+}
+
+- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
+ UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
+ NSAssert(keyWindow != nullptr,
+ @"The application must have a key window since the keyboard client "
+ @"must be part of the responder chain to function");
+
+ for (UIView* view in keyWindow.subviews) {
+ if ([view isKindOfClass:[FlutterTextInputView class]]) {
+ FlutterTextInputView* inputView = (FlutterTextInputView*)view;
+ inputView.isVisibleToAutofill = newVisibility;
}
}
}
+- (void)addToKeyWindowIfNeeded:(FlutterTextInputView*)inputView {
+ UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
+ NSAssert(keyWindow != nullptr,
+ @"The application must have a key window since the keyboard client "
+ @"must be part of the responder chain to function");
+
+ if (inputView.window != keyWindow) {
+ [keyWindow addSubview:inputView];
+ }
+}
+
- (void)setTextInputEditingState:(NSDictionary*)state {
if ([_activeView setTextInputState:state]) {
[_activeView updateEditingState];
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
index b2e6981d2b99a24ba718e60b946d1272d5e0d2ab..21b1d1cf5ae36ea131b8637fde47fb9b8d9546aa 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
@@ -10,14 +10,26 @@
FLUTTER_ASSERT_ARC
-@interface FlutterTextInputPluginTest : XCTestCase
-@end
-
@interface FlutterTextInputView ()
+@property(nonatomic, copy) NSString* autofillId;
+
- (void)setTextInputState:(NSDictionary*)state;
+- (BOOL)isVisibleToAutofill;
+@end
+
+@interface FlutterTextInputPlugin ()
+@property(nonatomic, strong) FlutterTextInputView* reusableInputView;
+@property(nonatomic, assign) FlutterTextInputView* activeView;
+@property(nonatomic, readonly)
+ NSMutableDictionary* autofillContext;
+@end
+
+@interface FlutterTextInputPluginTest : XCTestCase
@end
@implementation FlutterTextInputPluginTest {
+ NSDictionary* _template;
+ NSDictionary* _passwordTemplate;
id engine;
FlutterTextInputPlugin* textInputPlugin;
}
@@ -34,33 +46,93 @@ FLUTTER_ASSERT_ARC
[engine stopMocking];
[[[[textInputPlugin textInputView] superview] subviews]
makeObjectsPerformSelector:@selector(removeFromSuperview)];
+
[super tearDown];
}
-- (void)testSecureInput {
- NSDictionary* config = @{
- @"inputType" : @{@"name" : @"TextInuptType.text"},
- @"keyboardAppearance" : @"Brightness.light",
- @"obscureText" : @YES,
- @"inputAction" : @"TextInputAction.unspecified",
- @"smartDashesType" : @"0",
- @"smartQuotesType" : @"0",
- @"autocorrect" : @YES
- };
-
+- (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
- arguments:@[ @123, config ]];
-
+ arguments:@[ [NSNumber numberWithInt:clientId], config ]];
[textInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
+}
- // Find all the FlutterTextInputViews we created.
- NSArray* inputFields = [[[[textInputPlugin textInputView] superview]
- subviews]
- filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
+- (void)commitAutofillContextAndVerify {
+ FlutterMethodCall* methodCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
+ arguments:@YES];
+ [textInputPlugin handleMethodCall:methodCall
+ result:^(id _Nullable result){
+ }];
+
+ XCTAssertEqual(self.viewsVisibleToAutofill.count,
+ [textInputPlugin.activeView isVisibleToAutofill] ? 1 : 0);
+ XCTAssertNotEqual(textInputPlugin.textInputView, nil);
+ // The active view should still be installed so it doesn't get
+ // deallocated.
+ XCTAssertEqual(self.installedInputViews.count, 1);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
+}
+
+- (NSMutableDictionary*)mutableTemplateCopy {
+ if (!_template) {
+ _template = @{
+ @"inputType" : @{@"name" : @"TextInuptType.text"},
+ @"keyboardAppearance" : @"Brightness.light",
+ @"obscureText" : @NO,
+ @"inputAction" : @"TextInputAction.unspecified",
+ @"smartDashesType" : @"0",
+ @"smartQuotesType" : @"0",
+ @"autocorrect" : @YES
+ };
+ }
+
+ return [_template mutableCopy];
+}
+
+- (NSMutableDictionary*)mutablePasswordTemplateCopy {
+ if (!_passwordTemplate) {
+ _passwordTemplate = @{
+ @"inputType" : @{@"name" : @"TextInuptType.text"},
+ @"keyboardAppearance" : @"Brightness.light",
+ @"obscureText" : @YES,
+ @"inputAction" : @"TextInputAction.unspecified",
+ @"smartDashesType" : @"0",
+ @"smartQuotesType" : @"0",
+ @"autocorrect" : @YES
+ };
+ }
+
+ return [_passwordTemplate mutableCopy];
+}
+
+- (NSArray*)installedInputViews {
+ UIWindow* keyWindow =
+ [[[UIApplication sharedApplication] windows]
+ filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isKeyWindow == YES"]]
+ .firstObject;
+
+ return [keyWindow.subviews
+ filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
[FlutterTextInputView class]]];
+}
+
+- (NSArray*)viewsVisibleToAutofill {
+ return [self.installedInputViews
+ filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
+}
+
+#pragma mark - Tests
+
+- (void)testSecureInput {
+ NSDictionary* config = self.mutableTemplateCopy;
+ [config setValue:@"YES" forKey:@"obscureText"];
+ [self setClientId:123 configuration:config];
+
+ // Find all the FlutterTextInputViews we created.
+ NSArray* inputFields = self.installedInputViews;
// There are no autofill and the mock framework requested a secure entry. The first and only
// inserted FlutterTextInputView should be a secure text entry one.
@@ -75,6 +147,10 @@ FLUTTER_ASSERT_ARC
// The one FlutterTextInputView we inserted into the view hierarchy should be the text input
// plugin's active text input view.
XCTAssertEqual(inputView, textInputPlugin.textInputView);
+
+ // Despite not given an id in configuration, inputView has
+ // an autofill id.
+ XCTAssert(inputView.autofillId.length > 0);
}
- (void)testTextChangesTriggerUpdateEditingClient {
@@ -167,18 +243,9 @@ FLUTTER_ASSERT_ARC
}]]);
}
-- (void)testAutofillInputViews {
- NSDictionary* template = @{
- @"inputType" : @{@"name" : @"TextInuptType.text"},
- @"keyboardAppearance" : @"Brightness.light",
- @"obscureText" : @NO,
- @"inputAction" : @"TextInputAction.unspecified",
- @"smartDashesType" : @"0",
- @"smartQuotesType" : @"0",
- @"autocorrect" : @YES
- };
-
- NSMutableDictionary* field1 = [template mutableCopy];
+- (void)testAutofillContext {
+ NSMutableDictionary* field1 = self.mutableTemplateCopy;
+
[field1 setValue:@{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"hint1" ],
@@ -186,7 +253,7 @@ FLUTTER_ASSERT_ARC
}
forKey:@"autofill"];
- NSMutableDictionary* field2 = [template mutableCopy];
+ NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
[field2 setValue:@{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"hint2" ],
@@ -197,21 +264,160 @@ FLUTTER_ASSERT_ARC
NSMutableDictionary* config = [field1 mutableCopy];
[config setValue:@[ field1, field2 ] forKey:@"fields"];
- FlutterMethodCall* setClientCall =
- [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
- arguments:@[ @123, config ]];
+ [self setClientId:123 configuration:config];
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
- [textInputPlugin handleMethodCall:setClientCall
- result:^(id _Nullable result){
- }];
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
+ XCTAssertEqual(self.installedInputViews.count, 2);
+ XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
+
+ // The configuration changes.
+ NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
+ [field3 setValue:@{
+ @"uniqueIdentifier" : @"field3",
+ @"hints" : @[ @"hint3" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
+ // Replace field2 with field3.
+ [config setValue:@[ field1, field3 ] forKey:@"fields"];
+
+ [self setClientId:123 configuration:config];
+
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
+ XCTAssertEqual(self.installedInputViews.count, 3);
+ XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
+
+ // Old autofill input fields are still installed and reused.
+ for (NSString* key in oldContext.allKeys) {
+ XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
+ }
+
+ // Switch to a password field that has no contentType and is not in an AutofillGroup.
+ config = self.mutablePasswordTemplateCopy;
+
+ oldContext = textInputPlugin.autofillContext;
+ [self setClientId:124 configuration:config];
+
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
+ XCTAssertEqual(self.installedInputViews.count, 4);
+
+ // Old autofill input fields are still installed and reused.
+ for (NSString* key in oldContext.allKeys) {
+ XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
+ }
+ // The active view should change.
+ XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
+
+ // Switch to a similar password field, the previous field should be reused.
+ oldContext = textInputPlugin.autofillContext;
+ [self setClientId:200 configuration:config];
+
+ // Reuse the input view instance from the last time.
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
+ XCTAssertEqual(self.installedInputViews.count, 4);
+
+ // Old autofill input fields are still installed and reused.
+ for (NSString* key in oldContext.allKeys) {
+ XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
+ }
+ XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
+}
+
+- (void)testCommitAutofillContext {
+ NSMutableDictionary* field1 = self.mutableTemplateCopy;
+ [field1 setValue:@{
+ @"uniqueIdentifier" : @"field1",
+ @"hints" : @[ @"hint1" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
+ [field2 setValue:@{
+ @"uniqueIdentifier" : @"field2",
+ @"hints" : @[ @"hint2" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* field3 = self.mutableTemplateCopy;
+ [field3 setValue:@{
+ @"uniqueIdentifier" : @"field3",
+ @"hints" : @[ @"hint3" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* config = [field1 mutableCopy];
+ [config setValue:@[ field1, field2 ] forKey:@"fields"];
+
+ [self setClientId:123 configuration:config];
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
+
+ [self commitAutofillContextAndVerify];
+ XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
+
+ // Install the password field again.
+ [self setClientId:123 configuration:config];
+ // Switch to a regular autofill group.
+ [self setClientId:124 configuration:field3];
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
+ XCTAssertEqual(self.installedInputViews.count, 3);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
+ XCTAssertNotEqual(textInputPlugin.textInputView, nil);
+
+ [self commitAutofillContextAndVerify];
+ XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
+
+ // Now switch to an input field that does not autofill.
+ [self setClientId:125 configuration:self.mutableTemplateCopy];
+
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 0);
+ XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
+ // The active view should still be installed so it doesn't get
+ // deallocated.
+ XCTAssertEqual(self.installedInputViews.count, 1);
+ XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
+
+ [self commitAutofillContextAndVerify];
+ XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
+}
+
+- (void)testAutofillInputViews {
+ NSMutableDictionary* field1 = self.mutableTemplateCopy;
+ [field1 setValue:@{
+ @"uniqueIdentifier" : @"field1",
+ @"hints" : @[ @"hint1" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
+ [field2 setValue:@{
+ @"uniqueIdentifier" : @"field2",
+ @"hints" : @[ @"hint2" ],
+ @"editingValue" : @{@"text" : @""}
+ }
+ forKey:@"autofill"];
+
+ NSMutableDictionary* config = [field1 mutableCopy];
+ [config setValue:@[ field1, field2 ] forKey:@"fields"];
+
+ [self setClientId:123 configuration:config];
// Find all the FlutterTextInputViews we created.
- NSArray* inputFields = [[[[textInputPlugin textInputView] superview]
- subviews]
- filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
- [FlutterTextInputView class]]];
+ NSArray* inputFields = self.installedInputViews;
+ // Both fields are installed and visible because it's a password group.
XCTAssertEqual(inputFields.count, 2);
+ XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
// Find the inactive autofillable input field.
FlutterTextInputView* inactiveView = inputFields[1];
@@ -222,6 +428,22 @@ FLUTTER_ASSERT_ARC
OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]);
}
+- (void)testPasswordAutofillHack {
+ NSDictionary* config = self.mutableTemplateCopy;
+ [config setValue:@"YES" forKey:@"obscureText"];
+ [self setClientId:123 configuration:config];
+
+ // Find all the FlutterTextInputViews we created.
+ NSArray* inputFields = self.installedInputViews;
+
+ FlutterTextInputView* inputView = inputFields[0];
+
+ XCTAssert([inputView isKindOfClass:[UITextField class]]);
+ // FlutterSecureTextInputView does not respond to font,
+ // but it should return the default UITextField.font.
+ XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
+}
+
- (void)testAutocorrectionPromptRectAppears {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
inputView.textInputDelegate = engine;