未验证 提交 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;
......
......@@ -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.
先完成此消息的编辑!
想要评论请 注册