Skip to content

Commit 7d2042c

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 autocorrect 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 7d2042c

File tree

2 files changed

+223
-4
lines changed

2 files changed

+223
-4
lines changed

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

Lines changed: 64 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,13 @@
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 kAutocorrect = @"autocorrect";
41+
static NSString* const kSecureTextEntry = @"obscureText";
4142
static NSString* const kTextInputAction = @"inputAction";
4243
static NSString* const kEnableDeltaModel = @"enableDeltaModel";
4344
static NSString* const kTextInputType = @"inputType";
4445
static NSString* const kTextInputTypeName = @"name";
45-
4646
static NSString* const kSelectionBaseKey = @"selectionBase";
4747
static NSString* const kSelectionExtentKey = @"selectionExtent";
4848
static NSString* const kSelectionAffinityKey = @"selectionAffinity";
@@ -51,6 +51,17 @@
5151
static NSString* const kComposingExtentKey = @"composingExtent";
5252
static NSString* const kTextKey = @"text";
5353
static NSString* const kTransformKey = @"transform";
54+
static NSString* const kAssociatedAutofillFields = @"fields";
55+
56+
// TextInputConfiguration.autofill and sub-field names
57+
static NSString* const kAutofillProperties = @"autofill";
58+
static NSString* const kAutofillId = @"uniqueIdentifier";
59+
static NSString* const kAutofillEditingValue = @"editingValue";
60+
static NSString* const kAutofillHints = @"hints";
61+
62+
// TextAffinity types
63+
static NSString* const kTextAffinityDownstream = @"TextAffinity.downstream";
64+
static NSString* const kTextAffinityUpstream = @"TextAffinity.upstream";
5465

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

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

82141
// Internally marks that the event was received through performKeyEquivalent:.
@@ -317,6 +376,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
317376
NSDictionary* inputTypeInfo = config[kTextInputType];
318377
_inputType = inputTypeInfo[kTextInputTypeName];
319378
self.textAffinity = kFlutterTextAffinityUpstream;
379+
self.automaticTextCompletionEnabled = EnableAutocomplete(config);
320380

321381
_activeModel = std::make_unique<flutter::TextInputModel>();
322382
}

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)testAutocompleteDisabledWhenAutocorrectDisabled {
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+
@"autocorrect" : @NO,
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, TestAutocompleteDisabledWhenAutocorrectDisable) {
1508+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testAutocompleteDisabledWhenAutocorrectDisabled]);
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)