[macOS] Support text input autocomplete (#39460)

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
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm
index e4d97b5..c5bdca2 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 @@
   return flutter::TextRange([base unsignedLongValue], [extent unsignedLongValue]);
 }
 
+// Returns the autofill hint content type, if specified; otherwise nil.
+static NSString* GetAutofillContentType(NSDictionary* autofill) {
+  NSArray<NSString*>* 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 @@
       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<flutter::TextInputModel>();
     }
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm
index 4235080..1efc461 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm
@@ -387,6 +387,227 @@
   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 @@
   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]);
 }