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;