diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm index e4d97b5b7d30d..c5bdca27ce986 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm @@ -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"; @@ -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"; @@ -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 @@ -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* 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:. @@ -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(); } diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm index 42350807f37af..1efc461b08436 100644 --- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm @@ -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)); @@ -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]); }