mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Add autofill save for iOS and Android (flutter/engine#18643)
This commit is contained in:
parent
23fb7c08c6
commit
cfb0e1570f
@ -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,100 +1128,185 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
[_activeView resignFirstResponder];
|
||||
}
|
||||
|
||||
- (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];
|
||||
- (void)triggerAutofillSave:(BOOL)saveEntries {
|
||||
[self hideTextInput];
|
||||
|
||||
if (fields == nil) {
|
||||
_activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView;
|
||||
[FlutterTextInputPlugin setupInputView:_activeView withConfiguration:configuration];
|
||||
|
||||
if (_activeView.window != keyWindow) {
|
||||
[keyWindow addSubview:_activeView];
|
||||
}
|
||||
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 {
|
||||
NSAssert(clientUniqueId != nil, @"The client's unique id can't be null");
|
||||
for (FlutterTextInputView* view in _inputViews) {
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
|
||||
for (UIView* view in keyWindow.subviews) {
|
||||
if ([view isKindOfClass:[FlutterTextInputView class]]) {
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
}
|
||||
|
||||
[_inputViews removeAllObjects];
|
||||
|
||||
for (NSDictionary* field in fields) {
|
||||
FlutterTextInputView* newInputView = [[[FlutterTextInputView alloc] init] autorelease];
|
||||
newInputView.textInputDelegate = _textInputDelegate;
|
||||
[_inputViews addObject:newInputView];
|
||||
|
||||
NSString* autofillId = uniqueIdFromDictionary(field);
|
||||
newInputView.autofillId = autofillId;
|
||||
|
||||
if ([clientUniqueId isEqualToString:autofillId]) {
|
||||
_activeView = newInputView;
|
||||
}
|
||||
|
||||
[FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field];
|
||||
[keyWindow addSubview:newInputView];
|
||||
}
|
||||
[_autofillContext removeAllObjects];
|
||||
}
|
||||
|
||||
[self cleanUpViewHierarchy:YES clearText:!saveEntries];
|
||||
[self addToKeyWindowIfNeeded:_activeView];
|
||||
}
|
||||
|
||||
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
+ (void)setupInputView:(FlutterTextInputView*)inputView
|
||||
withConfiguration:(NSDictionary*)configuration {
|
||||
NSDictionary* inputType = configuration[@"inputType"];
|
||||
NSString* keyboardAppearance = configuration[@"keyboardAppearance"];
|
||||
NSDictionary* autofill = configuration[@"autofill"];
|
||||
|
||||
inputView.secureTextEntry = [configuration[@"obscureText"] boolValue];
|
||||
inputView.keyboardType = ToUIKeyboardType(inputType);
|
||||
inputView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]);
|
||||
inputView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration);
|
||||
|
||||
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;
|
||||
// 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];
|
||||
}
|
||||
if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) {
|
||||
inputView.keyboardAppearance = UIKeyboardAppearanceDark;
|
||||
} else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) {
|
||||
inputView.keyboardAppearance = UIKeyboardAppearanceLight;
|
||||
} else {
|
||||
inputView.keyboardAppearance = UIKeyboardAppearanceDefault;
|
||||
}
|
||||
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);
|
||||
|
||||
[_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;
|
||||
}
|
||||
|
||||
- (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);
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
for (NSDictionary* field in fields) {
|
||||
NSString* autofillId = autofillIdFromDictionary(field);
|
||||
NSAssert(autofillId, @"autofillId must not be null for field: %@", field);
|
||||
|
||||
BOOL hasHints = autofillTypeOf(field) != FlutterAutofillTypeNone;
|
||||
BOOL isFocused = [focusedId isEqualToString:autofillId];
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
inputView.textInputDelegate = _textInputDelegate;
|
||||
[inputView configureWithDictionary:field];
|
||||
return inputView;
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (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 {
|
||||
|
||||
@ -10,14 +10,26 @@
|
||||
|
||||
FLUTTER_ASSERT_ARC
|
||||
|
||||
@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
|
||||
|
||||
@interface FlutterTextInputView ()
|
||||
- (void)setTextInputState:(NSDictionary*)state;
|
||||
@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){
|
||||
}];
|
||||
}
|
||||
|
||||
- (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 = [[[[textInputPlugin textInputView] superview]
|
||||
subviews]
|
||||
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
|
||||
[FlutterTextInputView class]]];
|
||||
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
|
||||
};
|
||||
- (void)testAutofillContext {
|
||||
NSMutableDictionary* field1 = self.mutableTemplateCopy;
|
||||
|
||||
NSMutableDictionary* field1 = [template mutableCopy];
|
||||
[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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user