Skip to content

Commit 12c0860

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 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: flutter/flutter#119824
1 parent 684cfe2 commit 12c0860

File tree

2 files changed

+308
-4
lines changed

2 files changed

+308
-4
lines changed

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

Lines changed: 62 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,53 @@ 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* autofill) {
92+
NSArray<NSString*>* hints = autofill[kAutofillHints];
93+
return hints.count > 0 ? hints[0] : nil;
94+
}
95+
96+
// Returns YES if configuration describes a field for which autocomplete should be enabled for
97+
// the specified TextInputConfiguration. Autocomplete is enabled by default, but will be disabled
98+
// if the field is password-related, or if the configuration contains no autofill settings.
99+
static BOOL EnableAutocompleteForTextInputConfiguration(NSDictionary* configuration) {
100+
// Disable if obscureText is set.
101+
if ([configuration[kSecureTextEntry] boolValue]) {
102+
return NO;
103+
}
104+
105+
// Disable if autofill properties are not set.
106+
NSDictionary* autofill = configuration[kAutofillProperties];
107+
if (autofill == nil) {
108+
return NO;
109+
}
110+
111+
// Disable if autofill properties indicate a username/password.
112+
NSString* contentType = GetAutofillContentType(autofill);
113+
if ([contentType isEqualToString:@"password"] || [contentType isEqualToString:@"username"]) {
114+
return NO;
115+
}
116+
return YES;
117+
}
118+
119+
// Returns YES if configuration describes a field for which autocomplete should be enabled.
120+
// Autocomplete is enabled by default, but will be disabled if the field is password-related, or if
121+
// the configuration contains no autofill settings.
122+
//
123+
// In the case where the current field is part of an AutofillGroup, the configuration will have
124+
// a fields attribute with a list of TextInputConfigurations, one for each field. In the case where
125+
// any field in the group disables autocomplete, we disable it for all.
126+
static BOOL EnableAutocomplete(NSDictionary* configuration) {
127+
for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
128+
if (!EnableAutocompleteForTextInputConfiguration(field)) {
129+
return NO;
130+
}
131+
}
132+
133+
// Check the top-level TextInputConfiguration.
134+
return EnableAutocompleteForTextInputConfiguration(configuration);
135+
}
136+
80137
@interface NSEvent (KeyEquivalentMarker)
81138

82139
// Internally marks that the event was received through performKeyEquivalent:.
@@ -317,6 +374,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
317374
NSDictionary* inputTypeInfo = config[kTextInputType];
318375
_inputType = inputTypeInfo[kTextInputTypeName];
319376
self.textAffinity = kFlutterTextAffinityUpstream;
377+
self.automaticTextCompletionEnabled = EnableAutocomplete(config);
320378

321379
_activeModel = std::make_unique<flutter::TextInputModel>();
322380
}

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

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

390+
- (bool)testAutocompleteDisabledWhenAutofillNotSet {
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 disabled.
416+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
417+
return true;
418+
}
419+
420+
- (bool)testAutocompleteEnabledWhenAutofillSet {
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+
@"autofill" : @{
441+
@"uniqueIdentifier" : @"field1",
442+
@"hints" : @[ @"name" ],
443+
@"editingValue" : @{@"text" : @""},
444+
}
445+
}
446+
]]
447+
result:^(id){
448+
}];
449+
450+
// Verify autocomplete is enabled.
451+
EXPECT_TRUE([plugin isAutomaticTextCompletionEnabled]);
452+
return true;
453+
}
454+
455+
- (bool)testAutocompleteEnabledWhenAutofillSetNoHint {
456+
// Set up FlutterTextInputPlugin.
457+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
458+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
459+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
460+
[engineMock binaryMessenger])
461+
.andReturn(binaryMessengerMock);
462+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
463+
nibName:@""
464+
bundle:nil];
465+
FlutterTextInputPlugin* plugin =
466+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
467+
468+
// Set input client 1.
469+
[plugin handleMethodCall:[FlutterMethodCall
470+
methodCallWithMethodName:@"TextInput.setClient"
471+
arguments:@[
472+
@(1), @{
473+
@"inputAction" : @"action",
474+
@"inputType" : @{@"name" : @"inputName"},
475+
@"autofill" : @{
476+
@"uniqueIdentifier" : @"field1",
477+
@"hints" : @[],
478+
@"editingValue" : @{@"text" : @""},
479+
}
480+
}
481+
]]
482+
result:^(id){
483+
}];
484+
485+
// Verify autocomplete is enabled.
486+
EXPECT_TRUE([plugin isAutomaticTextCompletionEnabled]);
487+
return true;
488+
}
489+
490+
- (bool)testAutocompleteDisabledWhenObscureTextSet {
491+
// Set up FlutterTextInputPlugin.
492+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
493+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
494+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
495+
[engineMock binaryMessenger])
496+
.andReturn(binaryMessengerMock);
497+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
498+
nibName:@""
499+
bundle:nil];
500+
FlutterTextInputPlugin* plugin =
501+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
502+
503+
// Set input client 1.
504+
[plugin handleMethodCall:[FlutterMethodCall
505+
methodCallWithMethodName:@"TextInput.setClient"
506+
arguments:@[
507+
@(1), @{
508+
@"inputAction" : @"action",
509+
@"inputType" : @{@"name" : @"inputName"},
510+
@"obscureText" : @YES,
511+
@"autofill" : @{
512+
@"uniqueIdentifier" : @"field1",
513+
@"hints" : @[ @"name" ],
514+
@"editingValue" : @{@"text" : @""},
515+
}
516+
}
517+
]]
518+
result:^(id){
519+
}];
520+
521+
// Verify autocomplete is disabled.
522+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
523+
return true;
524+
}
525+
526+
- (bool)testAutocompleteDisabledWhenPasswordAutofillSet {
527+
// Set up FlutterTextInputPlugin.
528+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
529+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
530+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
531+
[engineMock binaryMessenger])
532+
.andReturn(binaryMessengerMock);
533+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
534+
nibName:@""
535+
bundle:nil];
536+
FlutterTextInputPlugin* plugin =
537+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
538+
539+
// Set input client 1.
540+
[plugin handleMethodCall:[FlutterMethodCall
541+
methodCallWithMethodName:@"TextInput.setClient"
542+
arguments:@[
543+
@(1), @{
544+
@"inputAction" : @"action",
545+
@"inputType" : @{@"name" : @"inputName"},
546+
@"autofill" : @{
547+
@"uniqueIdentifier" : @"field1",
548+
@"hints" : @[ @"password" ],
549+
@"editingValue" : @{@"text" : @""},
550+
}
551+
}
552+
]]
553+
result:^(id){
554+
}];
555+
556+
// Verify autocomplete is disabled.
557+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
558+
return true;
559+
}
560+
561+
- (bool)testAutocompleteDisabledWhenAutofillGroupIncludesPassword {
562+
// Set up FlutterTextInputPlugin.
563+
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
564+
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
565+
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
566+
[engineMock binaryMessenger])
567+
.andReturn(binaryMessengerMock);
568+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
569+
nibName:@""
570+
bundle:nil];
571+
FlutterTextInputPlugin* plugin =
572+
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
573+
574+
// Set input client 1.
575+
[plugin handleMethodCall:[FlutterMethodCall
576+
methodCallWithMethodName:@"TextInput.setClient"
577+
arguments:@[
578+
@(1), @{
579+
@"inputAction" : @"action",
580+
@"inputType" : @{@"name" : @"inputName"},
581+
@"fields" : @[
582+
@{
583+
@"inputAction" : @"action",
584+
@"inputType" : @{@"name" : @"inputName"},
585+
@"autofill" : @{
586+
@"uniqueIdentifier" : @"field1",
587+
@"hints" : @[ @"password" ],
588+
@"editingValue" : @{@"text" : @""},
589+
}
590+
},
591+
@{
592+
@"inputAction" : @"action",
593+
@"inputType" : @{@"name" : @"inputName"},
594+
@"autofill" : @{
595+
@"uniqueIdentifier" : @"field2",
596+
@"hints" : @[ @"name" ],
597+
@"editingValue" : @{@"text" : @""},
598+
}
599+
}
600+
]
601+
}
602+
]]
603+
result:^(id){
604+
}];
605+
606+
// Verify autocomplete is disabled.
607+
EXPECT_FALSE([plugin isAutomaticTextCompletionEnabled]);
608+
return true;
609+
}
610+
390611
- (bool)testFirstRectForCharacterRange {
391612
id engineMock = flutter::testing::CreateMockFlutterEngine(@"");
392613
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
@@ -1354,6 +1575,31 @@ - (bool)testSelectorsAreForwardedToFramework {
13541575
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
13551576
}
13561577

1578+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillNotSet) {
1579+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenAutofillNotSet]);
1580+
}
1581+
1582+
TEST(FlutterTextInputPluginTest, TestAutocompleteEnabledWhenAutofillSet) {
1583+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteEnabledWhenAutofillSet]);
1584+
}
1585+
1586+
TEST(FlutterTextInputPluginTest, TestAutocompleteEnabledWhenAutofillSetNoHint) {
1587+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteEnabledWhenAutofillSetNoHint]);
1588+
}
1589+
1590+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenObscureTextSet) {
1591+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenObscureTextSet]);
1592+
}
1593+
1594+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenPasswordAutofillSet) {
1595+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenPasswordAutofillSet]);
1596+
}
1597+
1598+
TEST(FlutterTextInputPluginTest, TestAutocompleteDisabledWhenAutofillGroupIncludesPassword) {
1599+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc]
1600+
testAutocompleteDisabledWhenAutofillGroupIncludesPassword]);
1601+
}
1602+
13571603
TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRange) {
13581604
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]);
13591605
}

0 commit comments

Comments
 (0)