From 09637dcea4a3f27fa04354f84666cdf747b7e8db Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Tue, 7 Feb 2023 10:58:21 -0800 Subject: [PATCH] [macOS] Support text input autocomplete By default, autocomplete is enabled during text input on macOS. On Macs with the touchbar enabled, the current text input and any suggested autocompletions are listed in the touchbar. This adds support for disabling autocomplete when autofill is disabled, when obscureText is set in the text input configuration, and when the autofill hint type is "password" or "username". When an AutofillGroup is in use, we disable autocomplete for all fields within the group when any of the fields disables autocomplete. While OS-level autocomplete support is far more robust on iOS, this behaviour matches our enable/disable state management behaviour on that platform. Issue: https://github.com/flutter/flutter/issues/119824 --- .../Source/FlutterTextInputPlugin.mm | 68 ++++- .../Source/FlutterTextInputPluginTest.mm | 246 ++++++++++++++++++ 2 files changed, 310 insertions(+), 4 deletions(-) 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]); }