Skip to content

Commit 0400ec0

Browse files
committed
[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 obscureText is set in the text input configuration, or if the autofill hint type is "password", "username", or the empty string. 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 out enable/disable state management behaviour on that platform. Issue: flutter/flutter#119824
1 parent 684cfe2 commit 0400ec0

File tree

2 files changed

+217
-4
lines changed

2 files changed

+217
-4
lines changed

shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

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

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

38-
static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream";
39-
static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream";
40-
39+
#pragma mark - TextInputConfiguration field names
40+
static NSString* const kSecureTextEntry = @"obscureText";
4141
static NSString* const kTextInputAction = @"inputAction";
4242
static NSString* const kEnableDeltaModel = @"enableDeltaModel";
4343
static NSString* const kTextInputType = @"inputType";
4444
static NSString* const kTextInputTypeName = @"name";
45-
4645
static NSString* const kSelectionBaseKey = @"selectionBase";
4746
static NSString* const kSelectionExtentKey = @"selectionExtent";
4847
static NSString* const kSelectionAffinityKey = @"selectionAffinity";
@@ -51,6 +50,17 @@
5150
static NSString* const kComposingExtentKey = @"composingExtent";
5251
static NSString* const kTextKey = @"text";
5352
static NSString* const kTransformKey = @"transform";
53+
static NSString* const kAssociatedAutofillFields = @"fields";
54+
55+
// TextInputConfiguration.autofill and sub-field names
56+
static NSString* const kAutofillProperties = @"autofill";
57+
static NSString* const kAutofillId = @"uniqueIdentifier";
58+
static NSString* const kAutofillEditingValue = @"editingValue";
59+
static NSString* const kAutofillHints = @"hints";
60+
61+
// TextAffinity types
62+
static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream";
63+
static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream";
5464

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

90+
// Returns the autofill hint content type, if specified; otherwise nil.
91+
static NSString* GetAutofillContentType(NSDictionary* configuration) {
92+
NSDictionary* autofill = configuration[kAutofillProperties];
93+
NSArray<NSString*>* hints = autofill[kAutofillHints];
94+
return hints.count > 0 ? hints[0] : nil;
95+
}
96+
97+
static BOOL EnableAutocompleteForTextInputConfiguration(NSDictionary* configuration) {
98+
// Disable if obscureText is set.
99+
if ([configuration[kSecureTextEntry] boolValue]) {
100+
return NO;
101+
}
102+
103+
// Disable if autofill properties indicate a username/password.
104+
NSString* contentType = GetAutofillContentType(configuration);
105+
if ([contentType isEqualToString:@"password"] || [contentType isEqualToString:@"username"]) {
106+
return NO;
107+
}
108+
109+
// Disable if autofill properties specify a blank autofill type.
110+
if ([contentType isEqualToString:@""]) {
111+
return NO;
112+
}
113+
return YES;
114+
}
115+
116+
// Returns YES if configuration describes a field for which autocomplete should be enabled.
117+
// Autocomplete is enabled by default, but will be disabled if the field is password-related,
118+
// or if the autofill hint is the empty string.
119+
static BOOL EnableAutocomplete(NSDictionary* configuration) {
120+
// Where an AutofillGroup has been used, the configuration includes a fields array listing a
121+
// TextInputConfiguration for each field. If autocomplete should be disabled for any of the
122+
// fields, disable autocorrect for all.
123+
for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
124+
if (!EnableAutocompleteForTextInputConfiguration(field)) {
125+
return NO;
126+
}
127+
}
128+
129+
// Check for the top-level TextInputConfiguration.
130+
return EnableAutocompleteForTextInputConfiguration(configuration);
131+
}
132+
80133
@interface NSEvent (KeyEquivalentMarker)
81134

82135
// Internally marks that the event was received through performKeyEquivalent:.
@@ -317,6 +370,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
317370
NSDictionary* inputTypeInfo = config[kTextInputType];
318371
_inputType = inputTypeInfo[kTextInputTypeName];
319372
self.textAffinity = kFlutterTextAffinityUpstream;
373+
self.automaticTextCompletionEnabled = EnableAutocomplete(config);
320374

321375
_activeModel = std::make_unique<flutter::TextInputModel>();
322376
}

shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,148 @@ - (bool)testClearClientDuringComposing {
387387
return true;
388388
}
389389

390+
- (bool)testAutocompleteEnabledByDefault {
391+
// Set up FlutterTextInputPlugin.
392+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
393+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
394+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
395+
[engineMock binaryMessenger])
396+
.andReturn(binaryMessengerMock);
397+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
398+
nibName:@""
399+
bundle:nil];
400+
FlutterTextInputPlugin* plugin =
401+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
402+
403+
// Set input client 1.
404+
[plugin handleMethodCall:[FlutterMethodCall
405+
methodCallWithMethodName:@"TextInput.setClient"
406+
arguments:@[
407+
@(1), @{
408+
@"inputAction" : @"action",
409+
@"inputType" : @{@"name" : @"inputName"},
410+
}
411+
]]
412+
result:^(id){
413+
}];
414+
415+
// Verify autocomplete is enabled.
416+
EXPECT_TRUE([plugin isAutomaticTextCompletionEnabled]);
417+
return true;
418+
}
419+
420+
- (bool)testAutocompleteDisabledWhenObscureTextIsSet {
421+
// Set up FlutterTextInputPlugin.
422+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
423+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
424+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
425+
[engineMock binaryMessenger])
426+
.andReturn(binaryMessengerMock);
427+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
428+
nibName:@""
429+
bundle:nil];
430+
FlutterTextInputPlugin* plugin =
431+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
432+
433+
// Set input client 1.
434+
[plugin handleMethodCall:[FlutterMethodCall
435+
methodCallWithMethodName:@"TextInput.setClient"
436+
arguments:@[
437+
@(1), @{
438+
@"inputAction" : @"action",
439+
@"inputType" : @{@"name" : @"inputName"},
440+
@"obscureText" : @YES,
441+
}
442+
]]
443+
result:^(id){
444+
}];
445+
446+
// Verify autocomplete is disabled.
447+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
448+
return true;
449+
}
450+
451+
- (bool)testAutocompleteDisabledWhenAutofillEmpty {
452+
// Set up FlutterTextInputPlugin.
453+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
454+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
455+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
456+
[engineMock binaryMessenger])
457+
.andReturn(binaryMessengerMock);
458+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
459+
nibName:@""
460+
bundle:nil];
461+
FlutterTextInputPlugin* plugin =
462+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
463+
464+
// Set input client 1.
465+
[plugin handleMethodCall:[FlutterMethodCall
466+
methodCallWithMethodName:@"TextInput.setClient"
467+
arguments:@[
468+
@(1), @{
469+
@"inputAction" : @"action",
470+
@"inputType" : @{@"name" : @"inputName"},
471+
@"autofill" : @{@"hints" : @[ @"" ]},
472+
}
473+
]]
474+
result:^(id){
475+
}];
476+
477+
// Verify autocomplete is disabled.
478+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
479+
return true;
480+
}
481+
482+
- (bool)testAutocompleteDisabledWhenAutofillGroupIncludesPassword {
483+
// Set up FlutterTextInputPlugin.
484+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
485+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
486+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
487+
[engineMock binaryMessenger])
488+
.andReturn(binaryMessengerMock);
489+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
490+
nibName:@""
491+
bundle:nil];
492+
FlutterTextInputPlugin* plugin =
493+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
494+
495+
// Set input client 1.
496+
[plugin handleMethodCall:[FlutterMethodCall
497+
methodCallWithMethodName:@"TextInput.setClient"
498+
arguments:@[
499+
@(1), @{
500+
@"inputAction" : @"action",
501+
@"inputType" : @{@"name" : @"inputName"},
502+
@"fields" : @[
503+
@{
504+
@"inputAction" : @"action",
505+
@"inputType" : @{@"name" : @"inputName"},
506+
@"autofill" : @{
507+
@"uniqueIdentifier" : @"field1",
508+
@"hints" : @[ @"password" ],
509+
@"editingValue" : @{@"text" : @""},
510+
}
511+
},
512+
@{
513+
@"inputAction" : @"action",
514+
@"inputType" : @{@"name" : @"inputName"},
515+
@"autofill" : @{
516+
@"uniqueIdentifier" : @"field2",
517+
@"hints" : @[ @"name" ],
518+
@"editingValue" : @{@"text" : @""},
519+
}
520+
}
521+
]
522+
}
523+
]]
524+
result:^(id){
525+
}];
526+
527+
// Verify autocomplete is disabled.
528+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
529+
return true;
530+
}
531+
390532
- (bool)testFirstRectForCharacterRange {
391533
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
392534
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
@@ -1354,6 +1496,23 @@ - (bool)testSelectorsAreForwardedToFramework {
13541496
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
13551497
}
13561498

1499+
TEST(FlutterTextInputPluginTest, TestAutocompleteEnabledByDefault) {
1500+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteEnabledByDefault]);
1501+
}
1502+
1503+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenObscureTextIsSet) {
1504+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenObscureTextIsSet]);
1505+
}
1506+
1507+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillEmpty) {
1508+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenAutofillEmpty]);
1509+
}
1510+
1511+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillGroupIncludesPassword) {
1512+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc]
1513+
testAutocompleteDisabledWhenAutofillGroupIncludesPassword]);
1514+
}
1515+
13571516
TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRange) {
13581517
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]);
13591518
}

0 commit comments

Comments
 (0)