未验证 提交 d96371e0 编写于 作者: L LongCatIsLooong 提交者: GitHub

Add autofill save for iOS and Android (#18643)

上级 db8c40b3
......@@ -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.
*
* <p>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);
......
......@@ -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) {
......
......@@ -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<BinaryMessenger.BinaryMessageHandler> 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;
......
......@@ -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<NSString*>* 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<FlutterTextInputView*>* inputViews;
@property(nonatomic, strong) FlutterTextInputView* reusableInputView;
// The current password-autofillable input fields that have yet to be saved.
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* 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];
......
......@@ -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<NSString*, FlutterTextInputView*>* 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<FlutterTextInputView*>* 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<FlutterTextInputView*>*)installedInputViews {
UIWindow* keyWindow =
[[[UIApplication sharedApplication] windows]
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isKeyWindow == YES"]]
.firstObject;
return [keyWindow.subviews
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
[FlutterTextInputView class]]];
}
- (NSArray<FlutterTextInputView*>*)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<FlutterTextInputView*>* 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<FlutterTextInputView*>* inputFields = [[[[textInputPlugin textInputView] superview]
subviews]
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
[FlutterTextInputView class]]];
NSArray<FlutterTextInputView*>* 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<FlutterTextInputView*>* 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;
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册