Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

static NSString* const kTextInputChannel = @"flutter/textinput";

#pragma mark - Textinput channel method names
// See https://api.flutter.dev/flutter/services/SystemChannels/textInput-constant.html
static NSString* const kSetClientMethod = @"TextInput.setClient";
static NSString* const kShowMethod = @"TextInput.show";
Expand All @@ -35,14 +36,12 @@
static NSString* const kPerformSelectors = @"TextInputClient.performSelectors";
static NSString* const kMultilineInputType = @"TextInputType.multiline";

static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream";
static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream";

#pragma mark - TextInputConfiguration field names
static NSString* const kSecureTextEntry = @"obscureText";
static NSString* const kTextInputAction = @"inputAction";
static NSString* const kEnableDeltaModel = @"enableDeltaModel";
static NSString* const kTextInputType = @"inputType";
static NSString* const kTextInputTypeName = @"name";

static NSString* const kSelectionBaseKey = @"selectionBase";
static NSString* const kSelectionExtentKey = @"selectionExtent";
static NSString* const kSelectionAffinityKey = @"selectionAffinity";
Expand All @@ -51,6 +50,17 @@
static NSString* const kComposingExtentKey = @"composingExtent";
static NSString* const kTextKey = @"text";
static NSString* const kTransformKey = @"transform";
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";

// TextAffinity types
static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream";
static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream";

/**
* The affinity of the current cursor position. If the cursor is at a position representing
Expand All @@ -77,6 +87,54 @@ typedef NS_ENUM(NSUInteger, FlutterTextAffinity) {
return flutter::TextRange([base unsignedLongValue], [extent unsignedLongValue]);
}

// Returns the autofill hint content type, if specified; otherwise nil.
static NSString* GetAutofillContentType(NSDictionary* autofill) {
NSArray<NSString*>* hints = autofill[kAutofillHints];
return hints.count > 0 ? hints[0] : nil;
}

// Returns YES if configuration describes a field for which autocomplete should be enabled for
// the specified TextInputConfiguration. Autocomplete is enabled by default, but will be disabled
// if the field is password-related, or if the configuration contains no autofill settings.
static BOOL EnableAutocompleteForTextInputConfiguration(NSDictionary* configuration) {
// Disable if obscureText is set.
if ([configuration[kSecureTextEntry] boolValue]) {
return NO;
}

// Disable if autofill properties are not set.
NSDictionary* autofill = configuration[kAutofillProperties];
if (autofill == nil) {
return NO;
}

// Disable if autofill properties indicate a username/password.
// See: https://github.com/flutter/flutter/issues/119824
NSString* contentType = GetAutofillContentType(autofill);
if ([contentType isEqualToString:@"password"] || [contentType isEqualToString:@"username"]) {
return NO;
}
return YES;
}

// Returns YES if configuration describes a field for which autocomplete should be enabled.
// Autocomplete is enabled by default, but will be disabled if the field is password-related, or if
// the configuration contains no autofill settings.
//
// In the case where the current field is part of an AutofillGroup, the configuration will have
// a fields attribute with a list of TextInputConfigurations, one for each field. In the case where
// any field in the group disables autocomplete, we disable it for all.
static BOOL EnableAutocomplete(NSDictionary* configuration) {
for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
if (!EnableAutocompleteForTextInputConfiguration(field)) {
return NO;
}
}

// Check the top-level TextInputConfiguration.
return EnableAutocompleteForTextInputConfiguration(configuration);
}

@interface NSEvent (KeyEquivalentMarker)

// Internally marks that the event was received through performKeyEquivalent:.
Expand Down Expand Up @@ -317,6 +375,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
NSDictionary* inputTypeInfo = config[kTextInputType];
_inputType = inputTypeInfo[kTextInputTypeName];
self.textAffinity = kFlutterTextAffinityUpstream;
self.automaticTextCompletionEnabled = EnableAutocomplete(config);
// TODO(cbracken): support text content types https://github.com/flutter/flutter/issues/120252

_activeModel = std::make_unique<flutter::TextInputModel>();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,227 @@ - (bool)testClearClientDuringComposing {
return true;
}

- (bool)testAutocompleteDisabledWhenAutofillNotSet {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];

// Verify autocomplete is disabled.
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testAutocompleteEnabledWhenAutofillSet {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"autofill" : @{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"name" ],
@"editingValue" : @{@"text" : @""},
}
}
]]
result:^(id){
}];

// Verify autocomplete is enabled.
EXPECT_TRUE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testAutocompleteEnabledWhenAutofillSetNoHint {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"autofill" : @{
@"uniqueIdentifier" : @"field1",
@"hints" : @[],
@"editingValue" : @{@"text" : @""},
}
}
]]
result:^(id){
}];

// Verify autocomplete is enabled.
EXPECT_TRUE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testAutocompleteDisabledWhenObscureTextSet {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"obscureText" : @YES,
@"autofill" : @{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"name" ],
@"editingValue" : @{@"text" : @""},
}
}
]]
result:^(id){
}];

// Verify autocomplete is disabled.
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testAutocompleteDisabledWhenPasswordAutofillSet {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"autofill" : @{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"password" ],
@"editingValue" : @{@"text" : @""},
}
}
]]
result:^(id){
}];

// Verify autocomplete is disabled.
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testAutocompleteDisabledWhenAutofillGroupIncludesPassword {
// Set up FlutterTextInputPlugin.
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];

// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"fields" : @[
@{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"autofill" : @{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"password" ],
@"editingValue" : @{@"text" : @""},
}
},
@{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
@"autofill" : @{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"name" ],
@"editingValue" : @{@"text" : @""},
}
}
]
}
]]
result:^(id){
}];

// Verify autocomplete is disabled.
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
return true;
}

- (bool)testFirstRectForCharacterRange {
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
Expand Down Expand Up @@ -1354,6 +1575,31 @@ - (bool)testSelectorsAreForwardedToFramework {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillNotSet) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenAutofillNotSet]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteEnabledWhenAutofillSet) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteEnabledWhenAutofillSet]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteEnabledWhenAutofillSetNoHint) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteEnabledWhenAutofillSetNoHint]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenObscureTextSet) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenObscureTextSet]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenPasswordAutofillSet) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenPasswordAutofillSet]);
}

TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillGroupIncludesPassword) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc]
testAutocompleteDisabledWhenAutofillGroupIncludesPassword]);
}

TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRange) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]);
}
Expand Down